From 936fca001d3683ba8e0f94b74aa589d78e0f8d66 Mon Sep 17 00:00:00 2001 From: James Perrin Date: Wed, 12 Jul 2023 13:15:33 +0100 Subject: [PATCH 1/2] implement editor locking so only one person can edit a recording a time UoM: MAT-468 --- .../mock/editor/ID-dual-stream-demo/edit.json | 3 +- src/main/Body.tsx | 7 +- src/main/Lock.tsx | 80 +++++++++++++++++++ src/redux/videoSlice.ts | 24 +++++- src/util/client.js | 12 ++- src/util/utilityFunctions.ts | 26 ++++++ 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 src/main/Lock.tsx diff --git a/.github/mock/editor/ID-dual-stream-demo/edit.json b/.github/mock/editor/ID-dual-stream-demo/edit.json index ddd7b664b..c6af0bb0c 100644 --- a/.github/mock/editor/ID-dual-stream-demo/edit.json +++ b/.github/mock/editor/ID-dual-stream-demo/edit.json @@ -53,5 +53,6 @@ "id": null, "title": null }, - "workflow_active": false + "workflow_active": false, + "locking_active": false } diff --git a/src/main/Body.tsx b/src/main/Body.tsx index 5daf030d1..2c7c409d3 100644 --- a/src/main/Body.tsx +++ b/src/main/Body.tsx @@ -5,6 +5,7 @@ import MainContent from './MainContent'; import TheEnd from './TheEnd'; import Error from './Error'; import Landing from "./Landing"; +import Lock from "./Lock"; import { css } from '@emotion/react' @@ -28,7 +29,10 @@ const Body: React.FC = () => { ) } else if (isEnd) { return ( - +
+ + +
); } else if (isError) { return ( @@ -37,6 +41,7 @@ const Body: React.FC = () => { } else { return (
+
diff --git a/src/main/Lock.tsx b/src/main/Lock.tsx new file mode 100644 index 000000000..87afbb0ef --- /dev/null +++ b/src/main/Lock.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from "react"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; + +import { useDispatch, useSelector } from "react-redux"; +import { settings } from "../config"; +import { setLock, video } from "../redux/videoSlice"; +import { selectIsEnd } from '../redux/endSlice' +import { setError } from "../redux/errorSlice"; +import { client } from "../util/client"; +import { useInterval } from "../util/utilityFunctions"; +import { useBeforeunload } from 'react-beforeunload'; + +const Lock: React.FC = () => { + const endpoint = `${settings.opencast.url}/editor/${settings.id}/lock` + + const dispatch = useDispatch(); + const lockingActive = useSelector((state: { videoState: { lockingActive: video["lockingActive"] } }) => state.videoState.lockingActive); + const lockRefresh = useSelector((state: { videoState: { lockRefresh: video["lockRefresh"] } }) => state.videoState.lockRefresh); + const lockState = useSelector((state: { videoState: { lockState: video["lockState"] } }) => state.videoState.lockState); + const lock = useSelector((state: { videoState: { lock: video["lock"] } }) => state.videoState.lock); + const isEnd = useSelector(selectIsEnd) + + function requestLock() { + const form = `user=${lock.user}&uuid=${lock.uuid}`; + client.post(endpoint, form, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' + } + }) + .then(() => dispatch(setLock(true))) + .catch((error: string) => { + dispatch(setLock(false)) + dispatch(setError({ + error: true, + errorDetails: error, + errorIcon: faLock, + errorTitle: 'Video editing locked', + errorMessage: 'This video is currently being edited by another user' + })) + }) + } + + function releaseLock() { + if (lockingActive && lockState) { + client.delete(endpoint + '/' + lock.uuid) + .then(() => { + console.info('Lock released') + dispatch(setLock(false)); + }) + } + } + + // Request lock + useEffect(() => { + if (lockingActive) { + requestLock() + } + }, [lockingActive]) + + // Refresh lock + useInterval(async () => { + requestLock() + }, lockingActive ? lockRefresh : null); + + // Release lock on leaving page + useBeforeunload((_event: { preventDefault: () => void; }) => { + releaseLock() + }) + + // Release lock on discard + useEffect(() => { + if (isEnd) { + releaseLock() + } + }, [isEnd]) + + return (<>) +} + +export default Lock; diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index 4bb612a3d..f51223319 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -27,6 +27,16 @@ export interface video { title: string, presenters: string[], workflows: Workflow[], + + lockingActive: boolean, // Whether locking event editing is enabled + lockRefresh: number | null, // Lock refresh period + lockState: boolean, // Whether lock has been obtained + lock: lockData +} + +export interface lockData { + uuid: string, + user: string } export const initialState: video & httpRequestState = { @@ -52,6 +62,11 @@ export const initialState: video & httpRequestState = { presenters: [], workflows: [], + lockingActive: false, + lockRefresh: null, + lockState: false, + lock: {uuid: '', user: ''}, + status: 'idle', error: undefined, errorReason: 'unknown', @@ -141,6 +156,9 @@ const videoSlice = createSlice({ const index = state.tracks.findIndex(t => t.id === action.payload) state.tracks[index].thumbnailUri = undefined }, + setLock: (state, action: PayloadAction) => { + state.lockState = action.payload; + }, cut: state => { // If we're exactly between two segments, we can't split the current segment if (state.segments[state.activeSegmentIndex].start === state.currentlyAt || @@ -219,6 +237,10 @@ const videoSlice = createSlice({ state.originalThumbnails = state.tracks.map((track: Track) => { return {id: track.id, uri: track.thumbnailUri} }) state.aspectRatios = new Array(state.videoCount) + state.lockingActive = action.payload.locking_active + state.lockRefresh = action.payload.lock_refresh + state.lock.uuid = action.payload.lock_uuid; + state.lock.user = action.payload.lock_user; }) builder.addCase( fetchVideoInformation.rejected, (state, action) => { @@ -336,7 +358,7 @@ const setThumbnailHelper = (state: video, id: Track["id"], uri: Track["thumbnail export const { setTrackEnabled, setIsPlaying, setIsPlayPreview, setCurrentlyAt, setCurrentlyAtInSeconds, addSegment, setAspectRatio, setHasChanges, setWaveformImages, setThumbnails, setThumbnail, removeThumbnail, - cut, markAsDeletedOrAlive, setSelectedWorkflowIndex, mergeLeft, mergeRight, setPreviewTriggered, + setLock, cut, markAsDeletedOrAlive, setSelectedWorkflowIndex, mergeLeft, mergeRight, setPreviewTriggered, setClickTriggered } = videoSlice.actions // Export selectors diff --git a/src/util/client.js b/src/util/client.js index eac60c18d..becfd4f14 100644 --- a/src/util/client.js +++ b/src/util/client.js @@ -4,7 +4,7 @@ import { settings } from '../config'; /** - * Client I stole this form a react tutorial + * Client I stole this from a react tutorial */ export async function client(endpoint, { body, ...customConfig } = {}) { const headers = { 'Content-Type': 'application/json' } @@ -29,7 +29,11 @@ export async function client(endpoint, { body, ...customConfig } = {}) { } if (body) { - config.body = JSON.stringify(body) + if (config.headers['Content-Type'].includes("urlencoded")) { + config.body = body + } else { + config.body = JSON.stringify(body) + } } let data @@ -63,3 +67,7 @@ client.get = function (endpoint, customConfig = {}) { client.post = function (endpoint, body, customConfig = {}) { return client(endpoint, { ...customConfig, body }) } + +client.delete = function (endpoint, customConfig = {}) { + return client(endpoint, { ...customConfig, method: 'DELETE' }) +} diff --git a/src/util/utilityFunctions.ts b/src/util/utilityFunctions.ts index 1bd3e7a89..71b767fd9 100644 --- a/src/util/utilityFunctions.ts +++ b/src/util/utilityFunctions.ts @@ -1,6 +1,7 @@ import { nanoid } from '@reduxjs/toolkit'; import { WebVTTParser, WebVTTSerializer } from 'webvtt-parser'; import { ExtendedSubtitleCue, SubtitleCue } from '../types'; +import { useEffect, useRef } from 'react'; export const roundToDecimalPlace = (num: number, decimalPlace: number) => { const decimalFactor = Math.pow(10, decimalPlace) @@ -160,3 +161,28 @@ export function parseSubtitle(subtitle: string) { return tree.cues } + +// Runs a callback every delay milliseconds +// Pass delay = null to stop +// Based off: https://overreacted.io/making-setinterval-declarative-with-react-hooks/ +type IntervalFunction = () => (unknown | void) +export function useInterval(callback: IntervalFunction, delay: number | null) { + + const savedCallback = useRef(null) + + useEffect(() => { + savedCallback.current = callback + }) + + useEffect(() => { + function tick() { + if (savedCallback.current !== null) { + savedCallback.current() + } + } + if (delay !== null) { + const id = setInterval(tick, delay) + return () => { clearInterval(id) } + } + }, [callback, delay]); +} From 9a1d8cee9f737003e9f394674419d0b7f089e7c4 Mon Sep 17 00:00:00 2001 From: James Perrin Date: Wed, 4 Oct 2023 14:48:34 +0100 Subject: [PATCH 2/2] Use react icon for locking --- src/main/Lock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/Lock.tsx b/src/main/Lock.tsx index 87afbb0ef..c3dda7897 100644 --- a/src/main/Lock.tsx +++ b/src/main/Lock.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from "react"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { LuLock } from "react-icons/lu"; import { useDispatch, useSelector } from "react-redux"; import { settings } from "../config"; @@ -33,7 +33,7 @@ const Lock: React.FC = () => { dispatch(setError({ error: true, errorDetails: error, - errorIcon: faLock, + errorIcon: LuLock, errorTitle: 'Video editing locked', errorMessage: 'This video is currently being edited by another user' }))