diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba72658..195092b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to ## Added - 🌐(frontend) Add German translation #255 +- ✨(frontend) Add a broadcast store #387 ## Changed @@ -22,6 +23,7 @@ and this project adheres to - 🦺(backend) add comma to sub regex #408 - 🐛(editor) collaborative user tag hidden when read only #385 +- 🐛(frontend) user have view access when revoked #387 ## [1.7.0] - 2024-10-24 diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index 5bbc50a5..0a8a7f90 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -18,7 +18,7 @@ import { useTrans, useUpdateDoc, } from '@/features/docs/doc-management'; -import { useResponsiveStore } from '@/stores'; +import { useBroadcastStore, useResponsiveStore } from '@/stores'; import { isFirefox } from '@/utils/userAgent'; interface DocTitleProps { @@ -54,6 +54,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { const headingText = headings?.[0]?.contentText; const debounceRef = useRef(); const { isMobile } = useResponsiveStore(); + const { broadcast } = useBroadcastStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], @@ -61,6 +62,9 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { if (data.title !== untitledDocument) { toast(t('Document title updated successfully'), VariantType.SUCCESS); } + + // Broadcast to every user connected to the document + broadcast(`${KEY_DOC}-${data.id}`); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx index 3dd539b7..37782048 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx @@ -1,7 +1,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc } from '@/features/docs'; +import { Doc, KEY_DOC } from '@/features/docs/doc-management'; +import { useBroadcastStore } from '@/stores'; export type UpdateDocLinkParams = Pick & Partial>; @@ -37,14 +38,20 @@ export function useUpdateDocLink({ listInvalideQueries, }: UpdateDocLinkProps = {}) { const queryClient = useQueryClient(); + const { broadcast } = useBroadcastStore(); + return useMutation({ mutationFn: updateDocLink, - onSuccess: (data) => { + onSuccess: (data, variable) => { listInvalideQueries?.forEach((queryKey) => { void queryClient.resetQueries({ queryKey: [queryKey], }); }); + + // Broadcast to every user connected to the document + broadcast(`${KEY_DOC}-${variable.id}`); + onSuccess?.(data); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx index 516d6bb3..a2230b3d 100644 --- a/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx +++ b/src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx @@ -5,11 +5,13 @@ import { User } from '@/core/auth'; import { Access, Doc, + KEY_DOC, KEY_LIST_DOC, Role, } from '@/features/docs/doc-management'; import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list'; import { ContentLanguage } from '@/i18n/types'; +import { useBroadcastStore } from '@/stores'; import { OptionType } from '../types'; @@ -53,9 +55,11 @@ export const createDocAccess = async ({ export function useCreateDocAccess() { const queryClient = useQueryClient(); + const { broadcast } = useBroadcastStore(); + return useMutation({ mutationFn: createDocAccess, - onSuccess: () => { + onSuccess: (_data, variable) => { void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC], }); @@ -65,6 +69,9 @@ export function useCreateDocAccess() { void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC_ACCESSES], }); + + // Broadcast to every user connected to the document + broadcast(`${KEY_DOC}-${variable.docId}`); }, }); } diff --git a/src/frontend/apps/impress/src/features/docs/members/members-list/api/useDeleteDocAccess.ts b/src/frontend/apps/impress/src/features/docs/members/members-list/api/useDeleteDocAccess.ts index debc6d59..b18414ae 100644 --- a/src/frontend/apps/impress/src/features/docs/members/members-list/api/useDeleteDocAccess.ts +++ b/src/frontend/apps/impress/src/features/docs/members/members-list/api/useDeleteDocAccess.ts @@ -7,6 +7,7 @@ import { import { APIError, errorCauses, fetchAPI } from '@/api'; import { KEY_DOC, KEY_LIST_DOC } from '@/features/docs/doc-management'; import { KEY_LIST_USER } from '@/features/docs/members/members-add'; +import { useBroadcastStore } from '@/stores'; import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; @@ -39,6 +40,8 @@ type UseDeleteDocAccessOptions = UseMutationOptions< export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => { const queryClient = useQueryClient(); + const { broadcast } = useBroadcastStore(); + return useMutation({ mutationFn: deleteDocAccess, ...options, @@ -49,6 +52,10 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => { void queryClient.invalidateQueries({ queryKey: [KEY_DOC], }); + + // Broadcast to every user connected to the document + broadcast(`${KEY_DOC}-${variables.docId}`); + void queryClient.resetQueries({ queryKey: [KEY_LIST_DOC], }); diff --git a/src/frontend/apps/impress/src/features/docs/members/members-list/api/useUpdateDocAccess.ts b/src/frontend/apps/impress/src/features/docs/members/members-list/api/useUpdateDocAccess.ts index b7192adf..3ff71bcc 100644 --- a/src/frontend/apps/impress/src/features/docs/members/members-list/api/useUpdateDocAccess.ts +++ b/src/frontend/apps/impress/src/features/docs/members/members-list/api/useUpdateDocAccess.ts @@ -11,6 +11,7 @@ import { KEY_LIST_DOC, Role, } from '@/features/docs/doc-management'; +import { useBroadcastStore } from '@/stores'; import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; @@ -49,6 +50,8 @@ type UseUpdateDocAccessOptions = UseMutationOptions< export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => { const queryClient = useQueryClient(); + const { broadcast } = useBroadcastStore(); + return useMutation({ mutationFn: updateDocAccess, ...options, @@ -59,6 +62,10 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => { void queryClient.invalidateQueries({ queryKey: [KEY_DOC], }); + + // Broadcast to every user connected to the document + broadcast(`${KEY_DOC}-${variables.docId}`); + void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC], }); diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 7a976bbf..b9d73257 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -1,4 +1,5 @@ import { Loader } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import Head from 'next/head'; import { useRouter as useNavigate } from 'next/navigation'; import { useRouter } from 'next/router'; @@ -8,8 +9,9 @@ import { Box, Text } from '@/components'; import { TextErrors } from '@/components/TextErrors'; import { useAuthStore } from '@/core/auth'; import { DocEditor } from '@/features/docs/doc-editor'; -import { useDoc, useDocStore } from '@/features/docs/doc-management'; +import { KEY_DOC, useDoc, useDocStore } from '@/features/docs/doc-management'; import { MainLayout } from '@/layouts'; +import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; export function DocLayout() { @@ -42,8 +44,10 @@ const DocPage = ({ id }: DocProps) => { const { data: docQuery, isError, error } = useDoc({ id }); const [doc, setDoc] = useState(docQuery); const { setCurrentDoc, createProvider, docsStore } = useDocStore(); - + const { setBroadcastProvider, addTask } = useBroadcastStore(); + const queryClient = useQueryClient(); const navigate = useNavigate(); + const provider = docsStore?.[id]?.provider; useEffect(() => { if (doc?.title) { @@ -71,12 +75,29 @@ const DocPage = ({ id }: DocProps) => { return; } - const provider = docsStore?.[doc.id]?.provider; - + let newProvider = provider; if (!provider || provider.document.guid !== doc.id) { - createProvider(doc.id, doc.content); + newProvider = createProvider(doc.id, doc.content); } - }, [createProvider, doc, docsStore]); + + setBroadcastProvider(newProvider); + }, [createProvider, doc, provider, setBroadcastProvider]); + + /** + * We add a broadcast task to reset the query cache + * when the document visibility changes. + */ + useEffect(() => { + if (!doc?.id) { + return; + } + + addTask(`${KEY_DOC}-${doc.id}`, () => { + void queryClient.resetQueries({ + queryKey: [KEY_DOC, { id: doc.id }], + }); + }); + }, [addTask, doc?.id, queryClient]); if (isError && error) { if (error.status === 404) { diff --git a/src/frontend/apps/impress/src/stores/index.ts b/src/frontend/apps/impress/src/stores/index.ts index ca763337..264f9dee 100644 --- a/src/frontend/apps/impress/src/stores/index.ts +++ b/src/frontend/apps/impress/src/stores/index.ts @@ -1 +1,2 @@ +export * from './useBroadcastStore'; export * from './useResponsiveStore'; diff --git a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx new file mode 100644 index 00000000..a6230ea6 --- /dev/null +++ b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx @@ -0,0 +1,54 @@ +import { HocuspocusProvider } from '@hocuspocus/provider'; +import * as Y from 'yjs'; +import { create } from 'zustand'; + +interface BroadcastState { + addTask: (taskLabel: string, action: () => void) => void; + broadcast: (taskLabel: string) => void; + getBroadcastProvider: () => HocuspocusProvider | undefined; + provider?: HocuspocusProvider; + setBroadcastProvider: (provider: HocuspocusProvider) => void; + tasks: { [taskLabel: string]: Y.Array }; +} + +export const useBroadcastStore = create((set, get) => ({ + provider: undefined, + tasks: {}, + setBroadcastProvider: (provider) => set({ provider }), + getBroadcastProvider: () => { + const provider = get().provider; + if (!provider) { + console.warn('Provider is not defined'); + return; + } + + return provider; + }, + addTask: (taskLabel, action) => { + const taskExistAlready = get().tasks[taskLabel]; + const provider = get().getBroadcastProvider(); + if (taskExistAlready || !provider) { + return; + } + + const task = provider.document.getArray(taskLabel); + task.observe(() => { + action(); + }); + + set((state) => ({ + tasks: { + ...state.tasks, + [taskLabel]: task, + }, + })); + }, + broadcast: (taskLabel) => { + const task = get().tasks[taskLabel]; + if (!task) { + console.warn(`Task ${taskLabel} is not defined`); + return; + } + task.push([`broadcast: ${taskLabel}`]); + }, +}));