wip broadcast encryption transition to adapt ui smoothly
This commit is contained in:
@@ -115,6 +115,11 @@ export class EncryptedWebSocket extends WebSocket {
|
||||
});
|
||||
}
|
||||
|
||||
// allow sending raw message without encryption so they can be read
|
||||
sendSystemMessage(message: string) {
|
||||
super.send(message);
|
||||
}
|
||||
|
||||
send(message: Uint8Array<ArrayBuffer>) {
|
||||
// TODO: we use the polyfilled websocket parameter for `y-websocket` to bring our own encryption logic over the network
|
||||
// that's great but encryption is preferable with async processes, we cannot just switch to async since
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { encryptContent } from '@/docs/doc-collaboration/encryption';
|
||||
import { useUpdateDoc } from '@/docs/doc-management/';
|
||||
import { useUpdateDoc, useProviderStore } from '@/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
@@ -20,6 +20,7 @@ export const useSaveDoc = (
|
||||
documentSymmetricKey: CryptoKey;
|
||||
} | null,
|
||||
) => {
|
||||
const { encryptionTransition } = useProviderStore();
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
onSuccess: () => {
|
||||
@@ -53,6 +54,8 @@ export const useSaveDoc = (
|
||||
const saveDoc = useCallback(() => {
|
||||
if (!isLocalChange) {
|
||||
return false;
|
||||
} else if (encryptionTransition) {
|
||||
return false;
|
||||
} else if (isEncrypted && !documentEncryptionSettings) {
|
||||
// If the symmetric key is not yet ready we just ignore saving (either it needs onboarding or just a few seconds)
|
||||
return false;
|
||||
@@ -82,6 +85,7 @@ export const useSaveDoc = (
|
||||
return true;
|
||||
}, [
|
||||
isLocalChange,
|
||||
encryptionTransition,
|
||||
updateDoc,
|
||||
docId,
|
||||
yDoc,
|
||||
|
||||
@@ -335,12 +335,6 @@ export const DocToolBox = ({
|
||||
<ModalEncryptDoc
|
||||
doc={doc}
|
||||
onClose={() => setIsModalEncryptOpen(false)}
|
||||
onSuccess={() => {
|
||||
//
|
||||
// TODO: probably it should make an hard refresh to get the setup
|
||||
// but it should before register content in database with accesses, and broadcast the information through websocket
|
||||
//
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isModalRemoveEncryptionOpen &&
|
||||
@@ -349,12 +343,6 @@ export const DocToolBox = ({
|
||||
doc={doc}
|
||||
symmetricKey={documentEncryptionSettings.documentSymmetricKey}
|
||||
onClose={() => setIsModalRemoveEncryptionOpen(false)}
|
||||
onSuccess={() => {
|
||||
//
|
||||
// TODO: probably it should make an hard refresh to get the setup
|
||||
// but it should before register content in database with clean accesses, and broadcast the information through websocket
|
||||
//
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectHistoryModal.isOpen && (
|
||||
|
||||
+21
-14
@@ -28,6 +28,7 @@ import { toBase64 } from '@/docs/doc-editor';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
LinkReach,
|
||||
@@ -123,17 +124,13 @@ const encryptRemoteAttachments = async (
|
||||
interface ModalEncryptDocProps {
|
||||
doc: Doc;
|
||||
onClose: () => void;
|
||||
onSuccess?: (doc: Doc) => void;
|
||||
}
|
||||
|
||||
export const ModalEncryptDoc = ({
|
||||
doc,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ModalEncryptDocProps) => {
|
||||
export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider } = useProviderStore();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
const { user } = useAuth();
|
||||
const { encryptionSettings } = useUserEncryption();
|
||||
const { mutateAsync: updateUser } = useUserUpdate();
|
||||
@@ -235,6 +232,8 @@ export const ModalEncryptDoc = ({
|
||||
return;
|
||||
}
|
||||
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_STARTED);
|
||||
|
||||
const documentSymmetricKey = await generateSymmetricKey();
|
||||
|
||||
// Their public key are base64 encoded, decoding the whole
|
||||
@@ -283,6 +282,10 @@ export const ModalEncryptDoc = ({
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
|
||||
// we have no need of patching back the current Yjs document with modifications
|
||||
// since an encryption success will refetch data from the backend
|
||||
ongoingDoc.destroy();
|
||||
|
||||
const encryptedContent = await encryptContent(
|
||||
new Uint8Array(ongoingDocState),
|
||||
documentSymmetricKey,
|
||||
@@ -300,17 +303,21 @@ export const ModalEncryptDoc = ({
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
// since the encrypted state has been committed with success with can apply it locally with adjusted encrypted attachments
|
||||
Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc));
|
||||
|
||||
onSuccess?.(doc);
|
||||
onClose();
|
||||
|
||||
toast(t('The document has been encrypted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
ongoingDoc.destroy();
|
||||
// notify other users before destroying the provider since websocket connection needed
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED);
|
||||
|
||||
// trigger the provider switch (hocuspocus → relay)
|
||||
startEncryptionTransition('encrypting');
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notifyOthers(EncryptionTransitionEvent.ENCRYPTION_CANCELED);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
|
||||
+22
-16
@@ -16,6 +16,7 @@ import { decryptContent } from '@/docs/doc-collaboration';
|
||||
import { createDocAttachment } from '@/docs/doc-editor/api';
|
||||
import {
|
||||
Doc,
|
||||
EncryptionTransitionEvent,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
extractAttachmentKeysAndMetadata,
|
||||
@@ -101,18 +102,17 @@ interface ModalRemoveDocEncryptionProps {
|
||||
doc: Doc;
|
||||
symmetricKey: CryptoKey;
|
||||
onClose: () => void;
|
||||
onSuccess?: (doc: Doc) => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDocEncryption = ({
|
||||
doc,
|
||||
symmetricKey,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ModalRemoveDocEncryptionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const { provider } = useProviderStore();
|
||||
const { provider, notifyOthers, startEncryptionTransition } =
|
||||
useProviderStore();
|
||||
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
@@ -141,6 +141,8 @@ export const ModalRemoveDocEncryption = ({
|
||||
setIsPending(true);
|
||||
|
||||
try {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED);
|
||||
|
||||
// clone the Yjs document since performing changes during decryption
|
||||
// that require backend confirmation
|
||||
const ongoingDoc = new Y.Doc();
|
||||
@@ -155,36 +157,40 @@ export const ModalRemoveDocEncryption = ({
|
||||
|
||||
const ongoingDocState = Y.encodeStateAsUpdate(ongoingDoc);
|
||||
|
||||
// we have no need of patching back the current Yjs document with modifications
|
||||
// since a removing encryption success will refetch data from the backend
|
||||
ongoingDoc.destroy();
|
||||
|
||||
await removeDocEncryption({
|
||||
docId: doc.id,
|
||||
content: ongoingDocState,
|
||||
attachmentKeyMapping,
|
||||
});
|
||||
|
||||
// apply the URL changes from the cloned doc to the live document
|
||||
Y.applyUpdate(provider!.document, Y.encodeStateAsUpdate(ongoingDoc));
|
||||
|
||||
onSuccess?.(doc);
|
||||
onClose();
|
||||
|
||||
toast(
|
||||
t('The document encryption has been removed.'),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 4000,
|
||||
},
|
||||
{ duration: 4000 },
|
||||
);
|
||||
|
||||
ongoingDoc.destroy();
|
||||
// notify other users before destroying the provider since websocket connection needed
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED);
|
||||
|
||||
// trigger the provider switch (relay → hocuspocus):
|
||||
startEncryptionTransition('removing-encryption');
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
notifyOthers(EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseKeyDown = keyboardAction(handleClose);
|
||||
const handleRemoveEncryptionKeyDown = keyboardAction(
|
||||
handleRemoveEncryption,
|
||||
);
|
||||
const handleRemoveEncryptionKeyDown = keyboardAction(handleRemoveEncryption);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
||||
+5
-2
@@ -17,7 +17,8 @@ export const useCollaboration = (
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
|
||||
const { user } = useAuth();
|
||||
const { provider, createProvider, destroyProvider } = useProviderStore();
|
||||
const { provider, createProvider, destroyProvider, encryptionTransition } =
|
||||
useProviderStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -26,7 +27,8 @@ export const useCollaboration = (
|
||||
!user ||
|
||||
isEncrypted === undefined ||
|
||||
(isEncrypted === true && !documentEncryptionSettings) ||
|
||||
provider
|
||||
provider ||
|
||||
encryptionTransition
|
||||
) {
|
||||
// TODO: make sure the logout would invalide this provider, also a change of local keys (after import...)
|
||||
return;
|
||||
@@ -92,6 +94,7 @@ export const useCollaboration = (
|
||||
user,
|
||||
isEncrypted,
|
||||
documentEncryptionSettings,
|
||||
encryptionTransition,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+88
-1
@@ -3,11 +3,25 @@ import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
|
||||
import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { createAdaptedEncryptedWebsocketClass } from '@/docs/doc-collaboration/encryptedWebsocket';
|
||||
import {
|
||||
EncryptedWebSocket,
|
||||
createAdaptedEncryptedWebsocketClass,
|
||||
} from '@/docs/doc-collaboration/encryptedWebsocket';
|
||||
import { RelayProvider } from '@/docs/doc-collaboration/relayProvider';
|
||||
|
||||
export enum EncryptionTransitionEvent {
|
||||
ENCRYPTION_STARTED = 'system:encryption-started',
|
||||
ENCRYPTION_SUCCEEDED = 'system:encryption-succeeded',
|
||||
ENCRYPTION_CANCELED = 'system:encryption-canceled',
|
||||
REMOVE_ENCRYPTION_STARTED = 'system:remove-encryption-started',
|
||||
REMOVE_ENCRYPTION_SUCCEEDED = 'system:remove-encryption-succeeded',
|
||||
REMOVE_ENCRYPTION_CANCELED = 'system:remove-encryption-canceled',
|
||||
}
|
||||
|
||||
export type SwitchableProvider = RelayProvider | HocuspocusProvider;
|
||||
|
||||
export type EncryptionTransitionType = 'encrypting' | 'removing-encryption';
|
||||
|
||||
export interface UseCollaborationStore {
|
||||
createProvider: (
|
||||
providerUrl: string,
|
||||
@@ -16,11 +30,15 @@ export interface UseCollaborationStore {
|
||||
symmetricKey?: CryptoKey,
|
||||
) => SwitchableProvider;
|
||||
destroyProvider: () => void;
|
||||
notifyOthers: (event: EncryptionTransitionEvent) => void;
|
||||
startEncryptionTransition: (type: EncryptionTransitionType) => void;
|
||||
clearEncryptionTransition: () => void;
|
||||
provider: SwitchableProvider | undefined;
|
||||
isConnected: boolean;
|
||||
isReady: boolean;
|
||||
isSynced: boolean;
|
||||
hasLostConnection: boolean;
|
||||
encryptionTransition: EncryptionTransitionType | null;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
|
||||
@@ -30,8 +48,34 @@ const defaultValues = {
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
encryptionTransition: null,
|
||||
};
|
||||
|
||||
function handleEncryptionSystemMessage(
|
||||
message: string,
|
||||
set: (partial: Partial<UseCollaborationStore>) => void,
|
||||
get: () => UseCollaborationStore,
|
||||
) {
|
||||
switch (message) {
|
||||
case EncryptionTransitionEvent.ENCRYPTION_STARTED:
|
||||
set({ encryptionTransition: 'encrypting' });
|
||||
break;
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_STARTED:
|
||||
set({ encryptionTransition: 'removing-encryption' });
|
||||
break;
|
||||
case EncryptionTransitionEvent.ENCRYPTION_SUCCEEDED:
|
||||
get().startEncryptionTransition('encrypting');
|
||||
break;
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_SUCCEEDED:
|
||||
get().startEncryptionTransition('removing-encryption');
|
||||
break;
|
||||
case EncryptionTransitionEvent.ENCRYPTION_CANCELED:
|
||||
case EncryptionTransitionEvent.REMOVE_ENCRYPTION_CANCELED:
|
||||
set({ encryptionTransition: null });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDocState, encryptionSymmetricKey) => {
|
||||
@@ -59,6 +103,8 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
onSystemMessage: (message) => {
|
||||
if (message === 'system:authenticated') {
|
||||
set({ isReady: true, isConnected: true });
|
||||
} else {
|
||||
handleEncryptionSystemMessage(message, set, get);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -163,6 +209,9 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
};
|
||||
});
|
||||
},
|
||||
onStateless: ({ payload }) => {
|
||||
handleEncryptionSystemMessage(payload, set, get);
|
||||
},
|
||||
onSynced: ({ state }) => {
|
||||
set({ isSynced: state, isReady: true });
|
||||
},
|
||||
@@ -187,6 +236,44 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
|
||||
return provider;
|
||||
},
|
||||
startEncryptionTransition: (type: EncryptionTransitionType) => {
|
||||
const provider = get().provider;
|
||||
|
||||
// switching between hocuspocus and relay servers, we have to properly close the current one
|
||||
if (provider) {
|
||||
provider.destroy();
|
||||
}
|
||||
|
||||
// set the right data so the page component has the indication it needs to fetch again document data
|
||||
set({
|
||||
encryptionTransition: type,
|
||||
provider: undefined,
|
||||
isConnected: false,
|
||||
isReady: false,
|
||||
isSynced: false,
|
||||
hasLostConnection: false,
|
||||
});
|
||||
},
|
||||
clearEncryptionTransition: () => {
|
||||
set({ encryptionTransition: null });
|
||||
},
|
||||
notifyOthers: (event: EncryptionTransitionEvent) => {
|
||||
const provider = get().provider;
|
||||
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider instanceof HocuspocusProvider) {
|
||||
provider.sendStateless(event);
|
||||
} else if (provider instanceof RelayProvider) {
|
||||
const ws = provider.ws as EncryptedWebSocket | null;
|
||||
|
||||
if (ws) {
|
||||
ws.sendSystemMessage(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
destroyProvider: () => {
|
||||
const provider = get().provider;
|
||||
if (provider) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { Spinner } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
import { Box, Icon, Loading, StyledLink, Text, TextErrors } from '@/components';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
@@ -70,7 +71,13 @@ interface DocProps {
|
||||
}
|
||||
|
||||
const DocPage = ({ id }: DocProps) => {
|
||||
const { hasLostConnection, resetLostConnection } = useProviderStore();
|
||||
const {
|
||||
hasLostConnection,
|
||||
resetLostConnection,
|
||||
encryptionTransition,
|
||||
clearEncryptionTransition,
|
||||
provider,
|
||||
} = useProviderStore();
|
||||
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
|
||||
const {
|
||||
data: docQuery,
|
||||
@@ -173,6 +180,39 @@ const DocPage = ({ id }: DocProps) => {
|
||||
}
|
||||
}, [hasLostConnection, doc?.id, queryClient, resetLostConnection]);
|
||||
|
||||
// when encryption transition destroys the provider, that's the signal to refetch the document
|
||||
useEffect(() => {
|
||||
if (encryptionTransition && !provider && doc?.id) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_DOC, { id: doc.id }],
|
||||
});
|
||||
}
|
||||
}, [encryptionTransition, provider, doc?.id, queryClient]);
|
||||
|
||||
// clear transition state once the doc has been refetched with updated state
|
||||
// and encryption settings are resolved (derived or cleared), unblocking useCollaboration()
|
||||
useEffect(() => {
|
||||
if (!encryptionTransition || provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
// this boolean check ensure the new document data has been properly fetch compared to the old data
|
||||
const docUpdated =
|
||||
encryptionTransition === 'encrypting'
|
||||
? doc?.is_encrypted === true
|
||||
: doc?.is_encrypted === false;
|
||||
|
||||
if (docUpdated && !documentEncryptionLoading) {
|
||||
clearEncryptionTransition();
|
||||
}
|
||||
}, [
|
||||
encryptionTransition,
|
||||
provider,
|
||||
doc?.is_encrypted,
|
||||
documentEncryptionLoading,
|
||||
clearEncryptionTransition,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docQuery || isFetching) {
|
||||
return;
|
||||
@@ -329,9 +369,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
<StyledLink href="/">
|
||||
<Button
|
||||
color="neutral"
|
||||
icon={
|
||||
<Icon iconName="home" $withThemeInherited />
|
||||
}
|
||||
icon={<Icon iconName="home" $withThemeInherited />}
|
||||
>
|
||||
{t('Back to home')}
|
||||
</Button>
|
||||
@@ -340,6 +378,25 @@ const DocPage = ({ id }: DocProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (encryptionTransition) {
|
||||
return (
|
||||
<Box
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$height="100%"
|
||||
$width="100%"
|
||||
$gap="md"
|
||||
>
|
||||
<Spinner />
|
||||
<Text $textAlign="center" $variation="secondary">
|
||||
{encryptionTransition === 'encrypting'
|
||||
? t('Document encryption in progress, please wait...')
|
||||
: t('Removing document encryption, please wait...')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
@@ -43,11 +43,13 @@ export const hocuspocusServer = new Server({
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
async beforeHandleMessage(data) {
|
||||
//
|
||||
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
|
||||
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
|
||||
// this should be propagated to all subscribers so they can also prepare to refresh their page
|
||||
//
|
||||
async onStateless({ payload, document, connection }) {
|
||||
// some interaction may require notifying other users so they adjust their UI, only let broadcast those events
|
||||
if (payload.startsWith('system:')) {
|
||||
// originator should manage it by himself
|
||||
document.broadcastStateless(payload, (conn) => {
|
||||
return conn !== connection;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,10 +21,7 @@ export function getRelayRoom(roomId: string): Set<ws.WebSocket> | undefined {
|
||||
return rooms.get(roomId);
|
||||
}
|
||||
|
||||
export function closeRelayConnections(
|
||||
roomId: string,
|
||||
userId?: string,
|
||||
): void {
|
||||
export function closeRelayConnections(roomId: string, userId?: string): void {
|
||||
const room = rooms.get(roomId);
|
||||
|
||||
if (!room) {
|
||||
@@ -76,16 +73,10 @@ export async function handleRelayServerConnection(
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
if (data.toString() === 'ongoingDecryption') {
|
||||
//
|
||||
// TODO: here or inside an equivalent listener "onMessage" to catch an event "ongoingEncryption"
|
||||
// so we can close all connections properly and clear data. It needs to check this information from the backend first with "fetchDocument"
|
||||
// this should be propagated to all subscribers so they can also prepare to refresh their page
|
||||
//
|
||||
return;
|
||||
}
|
||||
|
||||
// Relay blindly since this server is a passthrough due to encryption
|
||||
// relay to all other peers in the room (passthrough due to encryption)
|
||||
// it will also broadcast events about encryption transition, note that we don't close connections
|
||||
// for this from the server because the clients could retrieve connecting immediately, it's better letting
|
||||
// all clients reacting properly so they switch to the right provider
|
||||
for (const peer of Array.from(room)) {
|
||||
if (peer !== ws) {
|
||||
sendMessage(peer, data);
|
||||
|
||||
Reference in New Issue
Block a user