🛂(frontend) block edition only when not alone

We added a system to know if a user is alone
on a document or not. We adapt the
frontend to block the edition only
when the user is not alone on the document.
This commit is contained in:
Anthony LC
2025-07-03 20:38:50 +02:00
committed by Manuel Raynaud
parent 9a8f952210
commit 55979e4370
9 changed files with 252 additions and 116 deletions

View File

@@ -50,9 +50,10 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isConnectedToCollabServer = provider.isSynced;
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
useSaveDoc(doc.id, provider.document, !readOnly);
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;

View File

@@ -41,7 +41,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -73,7 +73,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false), {
renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
@@ -107,7 +107,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -143,7 +143,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true), {
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});
@@ -164,7 +164,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
wrapper: AppWrapper,
});

View File

@@ -10,7 +10,12 @@ import { toBase64 } from '../utils';
const SAVE_INTERVAL = 60000;
const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
@@ -49,10 +54,18 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
updateDoc({
id: docId,
content: toBase64(Y.encodeStateAsUpdate(yDoc)),
websocket: isConnectedToCollabServer,
});
return true;
}, [canSave, yDoc, docId, isLocalChange, updateDoc]);
}, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
const router = useRouter();

View File

@@ -32,7 +32,7 @@ export const AlertNetwork = () => {
<Box $direction="row" $gap={spacingsTokens['2xs']} $align="center">
<Icon iconName="mobiledata_off" $theme="warning" $variation="600" />
<Text $theme="warning" $variation="600" $weight={500}>
{t('Your network do not allow you to edit')}
{t('Others are editing. Your network prevent changes.')}
</Text>
</Box>
<BoxButton
@@ -50,7 +50,7 @@ export const AlertNetwork = () => {
$margin={{ top: 'auto' }}
/>
<Text $theme="warning" $variation="600" $weight="500" $size="xs">
{t('Know more')}
{t('Learn more')}
</Text>
</BoxButton>
</Box>
@@ -74,8 +74,8 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
onClose={() => onClose()}
rightActions={
<>
<Button aria-label={t('OK')} onClick={onClose}>
{t('OK')}
<Button aria-label={t('OK')} onClick={onClose} color="danger">
{t('I understand')}
</Button>
</>
}
@@ -88,24 +88,39 @@ export const AlertNetworkModal = ({ onClose }: AlertNetworkModalProps) => {
$align="flex-start"
$variation="1000"
>
{t("Why can't I edit?")}
{t("Why you can't edit the document?")}
</Text>
}
>
<Box
aria-label={t('Content modal to explain why the user cannot edit')}
className="--docs--modal-alert-network"
$margin={{ top: 'xs' }}
$margin={{ top: 'md' }}
>
<Text $size="sm" $variation="600">
{t(
'The network configuration of your workstation or internet connection does not allow editing shared documents.',
'Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.',
)}
</Text>
<Text $size="sm" $variation="600" $margin={{ top: 'xs' }}>
{t(
'Docs use WebSockets to enable real-time editing. These communication channels allow instant and bidirectional exchanges between your browser and our servers. To access collaborative editing, please contact your IT department to enable WebSockets.',
)}
<Text
$size="sm"
$variation="600"
$margin={{ top: 'xs' }}
$weight="bold"
$display="inline"
>
{t("This means you can't edit until others leave.")}{' '}
<Text
$size="sm"
$variation="600"
$margin={{ top: 'xs' }}
$weight="normal"
$display="inline"
>
{t(
'If you wish to be able to co-edit in real-time, contact your Information Systems Security Manager about allowing WebSockets.',
)}
</Text>
</Text>
</Box>
</Modal>

View File

@@ -0,0 +1,32 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
type DocCanEditResponse = { can_edit: boolean };
export const docCanEdit = async (id: string): Promise<DocCanEditResponse> => {
const response = await fetchAPI(`documents/${id}/can-edit/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<DocCanEditResponse>;
};
export const KEY_CAN_EDIT = 'doc-can-edit';
export function useDocCanEdit(
param: string,
queryConfig?: UseQueryOptions<
DocCanEditResponse,
APIError,
DocCanEditResponse
>,
) {
return useQuery<DocCanEditResponse, APIError, DocCanEditResponse>({
queryKey: [KEY_CAN_EDIT, param],
queryFn: () => docCanEdit(param),
...queryConfig,
});
}

View File

@@ -1,10 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs';
import { KEY_CAN_EDIT } from './useDocCanEdit';
export type UpdateDocParams = Pick<Doc, 'id'> &
Partial<Pick<Doc, 'content' | 'title'>>;
Partial<Pick<Doc, 'content' | 'title'>> & {
websocket?: boolean;
};
export const updateDoc = async ({
id,
@@ -24,25 +32,30 @@ export const updateDoc = async ({
return response.json() as Promise<Doc>;
};
interface UpdateDocProps {
onSuccess?: (data: Doc) => void;
type UseUpdateDoc = UseMutationOptions<Doc, APIError, Partial<Doc>> & {
listInvalideQueries?: string[];
}
};
export function useUpdateDoc({
onSuccess,
listInvalideQueries,
}: UpdateDocProps = {}) {
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
...queryConfig,
onSuccess: (data, variables, context) => {
queryConfig?.listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
queryKey: [queryKey],
});
});
onSuccess?.(data);
if (queryConfig?.onSuccess) {
void queryConfig.onSuccess(data, variables, context);
}
},
onError: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_CAN_EDIT],
});
},
});
}

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { useConfig } from '@/core';
import { useIsOffline } from '@/features/service-worker';
import { KEY_CAN_EDIT, useDocCanEdit } from '../api/useDocCanEdit';
import { useProviderStore } from '../stores';
import { Doc, LinkReach } from '../types';
@@ -13,31 +14,30 @@ export const useIsCollaborativeEditable = (doc: Doc) => {
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED;
const docHasMember = doc.nb_accesses_direct > 1;
const isUserReader = !doc.abilities.partial_update;
const isShared = docIsPublic || docIsAuth || docHasMember;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const { isOffline } = useIsOffline();
const _isEditable = isUserReader || isConnected || !isShared || isOffline;
const [isEditable, setIsEditable] = useState(true);
const [isLoading, setIsLoading] = useState(!_isEditable);
const {
data: { can_edit } = { can_edit: _isEditable },
isLoading: isLoadingCanEdit,
} = useDocCanEdit(doc.id, {
enabled: !_isEditable,
queryKey: [KEY_CAN_EDIT, doc.id],
staleTime: 0,
});
/**
* Connection can take a few seconds
*/
useEffect(() => {
const _isEditable = isConnected || !isShared || isOffline;
setIsLoading(true);
if (_isEditable) {
setIsEditable(true);
setIsLoading(false);
if (isLoadingCanEdit) {
return;
}
const timer = setTimeout(() => {
setIsEditable(false);
setIsLoading(false);
}, 5000);
return () => clearTimeout(timer);
}, [isConnected, isOffline, isShared]);
setIsEditable(can_edit);
setIsLoading(false);
}, [can_edit, isLoadingCanEdit]);
if (!conf?.COLLABORATION_WS_NOT_CONNECTED_READY_ONLY) {
return {