From c9cc8b89efd53804e8d02f0fbe05377649d5285c Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 08:17:03 +0800 Subject: [PATCH 01/15] fixing offline --- quadratic-client/src/app/debugFlags.ts | 3 ++ quadratic-client/src/app/events/events.ts | 1 + .../src/app/ui/menus/BottomBar/SyncState.tsx | 15 +++++- .../quadraticCore/coreClientMessages.ts | 8 ++- .../quadraticCore/quadraticCore.ts | 3 ++ .../quadraticCore/worker/coreClient.ts | 10 +++- .../quadraticCore/worker/offline.ts | 54 +++++++++++++------ quadratic-client/src/shared/utils/timeAgo.ts | 2 +- quadratic-core/.vscode/settings.json | 3 +- .../execution/control_transaction.rs | 5 ++ .../src/controller/execution/mod.rs | 1 + .../execution/receive_multiplayer.rs | 23 +------- 12 files changed, 86 insertions(+), 42 deletions(-) diff --git a/quadratic-client/src/app/debugFlags.ts b/quadratic-client/src/app/debugFlags.ts index 1994a3da74..37627dfd8e 100644 --- a/quadratic-client/src/app/debugFlags.ts +++ b/quadratic-client/src/app/debugFlags.ts @@ -56,6 +56,9 @@ export const debugShowLoadingHashes = debug && false; export const debugShowFileIO = debug && false; +// shows messages related to offline transaction +export const debugOffline = debug && true; + export const debugGridSettings = debug && false; export const debugShowMultiplayer = debug && false; diff --git a/quadratic-client/src/app/events/events.ts b/quadratic-client/src/app/events/events.ts index 5f57129061..703cd05dad 100644 --- a/quadratic-client/src/app/events/events.ts +++ b/quadratic-client/src/app/events/events.ts @@ -86,6 +86,7 @@ interface EventTypes { resizeHeadingColumn: (sheetId: string, column: number) => void; offlineTransactions: (transactions: number, operations: number) => void; + offlineTransactionsApplied: (timestamps: number[]) => void; codeEditor: () => void; cellMoving: (move: boolean) => void; diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index ac161d84a2..4e6ca8b6dc 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -2,6 +2,7 @@ import { events } from '@/app/events/events'; import { pluralize } from '@/app/helpers/pluralize'; import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer'; import { MultiplayerState } from '@/app/web-workers/multiplayerWebWorker/multiplayerClientMessages'; +import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; import { DropdownMenu, DropdownMenuContent, @@ -10,6 +11,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; +import { timeAgo } from '@/shared/utils/timeAgo'; import { Check, ErrorOutline } from '@mui/icons-material'; import { CircularProgress, Tooltip, useTheme } from '@mui/material'; import { useEffect, useState } from 'react'; @@ -19,6 +21,7 @@ export default function SyncState() { const theme = useTheme(); const [syncState, setSyncState] = useState(multiplayer.state); + const { addGlobalSnackbar } = useGlobalSnackbar(); useEffect(() => { const updateState = (state: MultiplayerState) => setSyncState(state); @@ -34,10 +37,20 @@ export default function SyncState() { setUnsavedTransactions(transactions); }; events.on('offlineTransactions', updateUnsavedTransactions); + + const offlineTransactionsApplied = (timestamps: number[]) => { + if (timestamps.length === 0) return; + const to = timeAgo(timestamps[timestamps.length - 1]) + const message = `We applied ${timestamps.length} unsynced changes from ${to}. You can undo these changes.`; + addGlobalSnackbar(message, { severity: 'warning' }); + }; + events.on('offlineTransactionsApplied', offlineTransactionsApplied); + return () => { events.off('offlineTransactions', updateUnsavedTransactions); + events.off('offlineTransactionsApplied', offlineTransactionsApplied); }; - }, []); + }, [addGlobalSnackbar]); const [open, setOpen] = useState(false); diff --git a/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts b/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts index 72f87ebbd4..6fcf94d2ec 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts @@ -778,6 +778,11 @@ export interface CoreClientOfflineTransactions { operations: number; } +export interface CoreClientOfflineTransactionsApplied { + type: 'coreClientOfflineTransactionsApplied'; + timestamps: number[]; +} + export interface CoreClientUndoRedo { type: 'coreClientUndoRedo'; undo: boolean; @@ -921,4 +926,5 @@ export type CoreClientMessage = | CoreClientGetFormatRow | CoreClientGetFormatCell | CoreClientSheetMetaFills - | CoreClientSetCursorSelection; + | CoreClientSetCursorSelection + | CoreClientOfflineTransactionsApplied; diff --git a/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts b/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts index 87d42c43a4..f3693a9150 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts @@ -137,6 +137,9 @@ class QuadraticCore { } else if (e.data.type === 'coreClientOfflineTransactionStats') { events.emit('offlineTransactions', e.data.transactions, e.data.operations); return; + } else if (e.data.type === 'coreClientOfflineTransactionsApplied') { + events.emit('offlineTransactionsApplied', e.data.timestamps); + return; } else if (e.data.type === 'coreClientUndoRedo') { events.emit('undoRedo', e.data.undo, e.data.redo); return; diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/coreClient.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/coreClient.ts index a201b9e256..17d805135c 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/coreClient.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/coreClient.ts @@ -607,8 +607,14 @@ class CoreClient { sendOfflineTransactionStats() { this.send({ type: 'coreClientOfflineTransactionStats', - transactions: offline.stats.transactions, - operations: offline.stats.operations, + ...offline.stats, + }); + } + + sendOfflineTransactionsApplied(timestamps: number[]) { + this.send({ + type: 'coreClientOfflineTransactionsApplied', + timestamps, }); } diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts index 9ae97ec57f..bab51c2ba5 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts @@ -1,4 +1,4 @@ -import { debugShowFileIO } from '@/app/debugFlags'; +import { debugOffline } from '@/app/debugFlags'; import { core } from './core'; import { coreClient } from './coreClient'; @@ -11,6 +11,20 @@ declare var self: WorkerGlobalScope & addUnsentTransaction: (transactionId: string, transaction: string, operations: number) => void; }; +interface OfflineEntry { + fileId: string; + transactionId: string; + transaction: string; + operations: number; + index: number; + timestamp: number; +} + +interface OfflineStats { + transactions: number; + operations: number; +} + class Offline { private db: IDBDatabase | undefined; private index = 0; @@ -19,7 +33,7 @@ class Offline { // The `stats.operations` are not particularly interesting right now because // we send the entire operations batched together; we'll need to send partial // messages with separate operations to get good progress information. - stats = { transactions: 0, operations: 0 }; + stats: OfflineStats = { transactions: 0, operations: 0 }; // Creates a connection to the indexedDb database init(fileId: string): Promise { @@ -63,7 +77,7 @@ class Offline { } // Loads the unsent transactions for this file from indexedDb - async load(): Promise<{ transactionId: string; transactions: string }[] | undefined> { + async load(): Promise<{ transactionId: string; transactions: string; timestamp?: number }[] | undefined> { if (!this.fileId) return undefined; return new Promise((resolve) => { const store = this.getFileIndex(true, 'fileId'); @@ -77,6 +91,7 @@ class Offline { transactionId: r.transactionId, transactions: r.transaction, operations: r.operations ?? 0, + timestamp: r.timeStamp, }; }); // set the index to the length of the results so that we can add new transactions to the end @@ -86,6 +101,10 @@ class Offline { operations: results.reduce((acc, r) => acc + r.operations, 0), }; coreClient.sendOfflineTransactionStats(); + console.log(results); + coreClient.sendOfflineTransactionsApplied( + results.flatMap((r) => (r.timestamp ? [r.timestamp] : [])).sort((a, b) => b - a) + ); resolve(results); }; }); @@ -95,11 +114,19 @@ class Offline { addUnsentTransaction(transactionId: string, transaction: string, operations: number) { const store = this.getObjectStore(false); if (!this.fileId) throw new Error("Expected fileId to be set in 'addUnsentTransaction' method."); - store.add({ fileId: this.fileId, transactionId, transaction, operations, index: this.index++ }); + const offlineEntry: OfflineEntry = { + fileId: this.fileId, + transactionId, + transaction, + operations, + index: this.index++, + timestamp: Date.now(), + }; + store.add(offlineEntry); this.stats.transactions++; this.stats.operations += operations; coreClient.sendOfflineTransactionStats(); - if (debugShowFileIO) { + if (debugOffline) { console.log(`[Offline] Added transaction ${transactionId} to indexedDB.`); } } @@ -114,11 +141,11 @@ class Offline { this.stats.operations -= cursor.value.operations; coreClient.sendOfflineTransactionStats(); cursor.delete(); - if (debugShowFileIO) { + if (debugOffline) { console.log(`[Offline] Removed transaction ${transactionId} from indexedDB.`); } } else { - if (debugShowFileIO) { + if (debugOffline) { console.log(`[Offline] Failed to remove transaction ${transactionId} from indexedDB (might not exist).`); } } @@ -143,27 +170,24 @@ class Offline { // Loads unsent transactions and applies them to the grid. This is called twice: once after the grid and pixi loads; // and a second time when the socket server connects. async loadTransactions() { + console.log('loadTransaction'); const unsentTransactions = await this.load(); - if (debugShowFileIO) { + if (debugOffline) { if (unsentTransactions?.length) { - console.log('[Offline] Loading unsent transactions from indexedDB.'); + console.log(`[Offline] Loading ${unsentTransactions.length} unsent transactions from indexedDB.`); } else { console.log('[Offline] No unsent transactions in indexedDB.'); } } - // We need to apply the transactions in the opposite order to which they - // were saved because core will rollback old transactions before applying - // new ones if (unsentTransactions?.length) { - for (let i = unsentTransactions.length - 1; i >= 0; i--) { - const tx = unsentTransactions[i]; + unsentTransactions.forEach((tx) => { // we remove the transaction is there was a problem applying it (eg, if // the schema has changed since it was saved offline) if (!core.applyOfflineUnsavedTransaction(tx.transactionId, tx.transactions)) { this.markTransactionSent(tx.transactionId); } - } + }); } } diff --git a/quadratic-client/src/shared/utils/timeAgo.ts b/quadratic-client/src/shared/utils/timeAgo.ts index d8abf37008..f4cd11995b 100644 --- a/quadratic-client/src/shared/utils/timeAgo.ts +++ b/quadratic-client/src/shared/utils/timeAgo.ts @@ -16,7 +16,7 @@ const DIVISIONS: { amount: number; name: Intl.RelativeTimeFormatUnit }[] = [ { amount: Number.POSITIVE_INFINITY, name: 'years' }, ]; -export function timeAgo(dateString: string) { +export function timeAgo(dateString: string | number) { const date: Date = new Date(dateString); let duration = (date.getTime() - new Date().getTime()) / 1000; diff --git a/quadratic-core/.vscode/settings.json b/quadratic-core/.vscode/settings.json index b0874b278e..a409a94091 100644 --- a/quadratic-core/.vscode/settings.json +++ b/quadratic-core/.vscode/settings.json @@ -3,7 +3,8 @@ "bigdecimal", "dbgjs", "htmlescape", - "Rects" + "Rects", + "undoable" ], "lldb.displayFormat": "auto", "lldb.showDisassembly": "never", diff --git a/quadratic-core/src/controller/execution/control_transaction.rs b/quadratic-core/src/controller/execution/control_transaction.rs index a4b7347faa..3f4cf5eb13 100644 --- a/quadratic-core/src/controller/execution/control_transaction.rs +++ b/quadratic-core/src/controller/execution/control_transaction.rs @@ -52,6 +52,11 @@ impl GridController { .unsaved_transactions .insert_or_replace(transaction, true); } + TransactionType::Unsaved => { + let undo = transaction.to_undo_transaction(); + self.undo_stack.push(undo.clone()); + self.redo_stack.clear(); + } TransactionType::Undo => { let undo = transaction.to_undo_transaction(); self.redo_stack.push(undo.clone()); diff --git a/quadratic-core/src/controller/execution/mod.rs b/quadratic-core/src/controller/execution/mod.rs index 25027d7521..bb50d61b44 100644 --- a/quadratic-core/src/controller/execution/mod.rs +++ b/quadratic-core/src/controller/execution/mod.rs @@ -18,6 +18,7 @@ pub enum TransactionType { Redo, Multiplayer, Server, + Unsaved, } impl GridController { diff --git a/quadratic-core/src/controller/execution/receive_multiplayer.rs b/quadratic-core/src/controller/execution/receive_multiplayer.rs index 20766cf1ba..e96b1df024 100644 --- a/quadratic-core/src/controller/execution/receive_multiplayer.rs +++ b/quadratic-core/src/controller/execution/receive_multiplayer.rs @@ -239,8 +239,6 @@ impl GridController { } /// Called by TS for each offline transaction it has in its offline queue. - /// - /// Returns a [`TransactionSummary`] that will be rendered by the client. pub fn apply_offline_unsaved_transaction( &mut self, transaction_id: Uuid, @@ -261,32 +259,15 @@ impl GridController { } } else { let transaction = &mut PendingTransaction { - transaction_type: TransactionType::Multiplayer, + transaction_type: TransactionType::Unsaved, ..Default::default() }; transaction .operations .extend(unsaved_transaction.forward.operations.clone()); - // apply unsaved transaction - self.rollback_unsaved_transactions(); self.start_transaction(transaction); - self.reapply_unsaved_transactions(); - - self.transactions - .unsaved_transactions - .push(unsaved_transaction.clone()); - if cfg!(target_family = "wasm") { - if let Ok(operations) = - serde_json::to_string(&unsaved_transaction.forward.operations) - { - crate::wasm_bindings::js::jsSendTransaction( - transaction_id.to_string(), - operations, - ); - } - } - transaction.send_transaction(); + self.finalize_transaction(transaction); } } } From 73b949e4fe192da2271c0f132b28d8a9b9c59970 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:07:46 +0800 Subject: [PATCH 02/15] undo operations based on offline --- .../quadraticCore/worker/offline.ts | 8 +++---- .../pending_transaction.rs | 22 +++++++++++++++++++ .../execution/control_transaction.rs | 1 + .../execution/receive_multiplayer.rs | 3 +-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts index bab51c2ba5..71a057dfc2 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts @@ -91,7 +91,7 @@ class Offline { transactionId: r.transactionId, transactions: r.transaction, operations: r.operations ?? 0, - timestamp: r.timeStamp, + timestamp: r.timestamp, }; }); // set the index to the length of the results so that we can add new transactions to the end @@ -101,10 +101,8 @@ class Offline { operations: results.reduce((acc, r) => acc + r.operations, 0), }; coreClient.sendOfflineTransactionStats(); - console.log(results); - coreClient.sendOfflineTransactionsApplied( - results.flatMap((r) => (r.timestamp ? [r.timestamp] : [])).sort((a, b) => b - a) - ); + const timestamps = results.flatMap((r) => (r.timestamp ? [r.timestamp] : [])).sort((a, b) => a - b); + coreClient.sendOfflineTransactionsApplied(timestamps); resolve(results); }; }); diff --git a/quadratic-core/src/controller/active_transactions/pending_transaction.rs b/quadratic-core/src/controller/active_transactions/pending_transaction.rs index 177f04ac66..7efac58381 100644 --- a/quadratic-core/src/controller/active_transactions/pending_transaction.rs +++ b/quadratic-core/src/controller/active_transactions/pending_transaction.rs @@ -149,6 +149,7 @@ impl PendingTransaction { pub fn is_user(&self) -> bool { matches!(self.transaction_type, TransactionType::User) + || matches!(self.transaction_type, TransactionType::Unsaved) } pub fn is_undo_redo(&self) -> bool { @@ -206,4 +207,25 @@ mod tests { assert_eq!(reverse_transaction.operations, reverse_operations); assert_eq!(reverse_transaction.sequence_num, None); } + + #[test] + fn is_user() { + let transaction = PendingTransaction { + transaction_type: TransactionType::User, + ..Default::default() + }; + assert!(transaction.is_user()); + + let transaction = PendingTransaction { + transaction_type: TransactionType::Unsaved, + ..Default::default() + }; + assert!(transaction.is_user()); + + let transaction = PendingTransaction { + transaction_type: TransactionType::Server, + ..Default::default() + }; + assert!(!transaction.is_user()); + } } diff --git a/quadratic-core/src/controller/execution/control_transaction.rs b/quadratic-core/src/controller/execution/control_transaction.rs index 3f4cf5eb13..e33131cc97 100644 --- a/quadratic-core/src/controller/execution/control_transaction.rs +++ b/quadratic-core/src/controller/execution/control_transaction.rs @@ -54,6 +54,7 @@ impl GridController { } TransactionType::Unsaved => { let undo = transaction.to_undo_transaction(); + dbgjs!(&undo); self.undo_stack.push(undo.clone()); self.redo_stack.clear(); } diff --git a/quadratic-core/src/controller/execution/receive_multiplayer.rs b/quadratic-core/src/controller/execution/receive_multiplayer.rs index e96b1df024..9d55b986bd 100644 --- a/quadratic-core/src/controller/execution/receive_multiplayer.rs +++ b/quadratic-core/src/controller/execution/receive_multiplayer.rs @@ -153,8 +153,6 @@ impl GridController { } /// Used by the client to ensure transactions are applied in order - /// - /// Returns a [`TransactionSummary`] that will be rendered by the client. fn client_apply_transaction( &mut self, transaction: &mut PendingTransaction, @@ -259,6 +257,7 @@ impl GridController { } } else { let transaction = &mut PendingTransaction { + id: transaction_id, transaction_type: TransactionType::Unsaved, ..Default::default() }; From e354ae7d90f2f1b9436165903ac41e90c5a722f1 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:08:53 +0800 Subject: [PATCH 03/15] remove console.log --- quadratic-core/src/controller/execution/control_transaction.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/quadratic-core/src/controller/execution/control_transaction.rs b/quadratic-core/src/controller/execution/control_transaction.rs index e33131cc97..3f4cf5eb13 100644 --- a/quadratic-core/src/controller/execution/control_transaction.rs +++ b/quadratic-core/src/controller/execution/control_transaction.rs @@ -54,7 +54,6 @@ impl GridController { } TransactionType::Unsaved => { let undo = transaction.to_undo_transaction(); - dbgjs!(&undo); self.undo_stack.push(undo.clone()); self.redo_stack.clear(); } From 08f7bd2a8810becd971f9b4ed4523e43c6a06810 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:10:22 +0800 Subject: [PATCH 04/15] debugflag --- quadratic-client/src/app/debugFlags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quadratic-client/src/app/debugFlags.ts b/quadratic-client/src/app/debugFlags.ts index 37627dfd8e..c049e5bebd 100644 --- a/quadratic-client/src/app/debugFlags.ts +++ b/quadratic-client/src/app/debugFlags.ts @@ -57,7 +57,7 @@ export const debugShowLoadingHashes = debug && false; export const debugShowFileIO = debug && false; // shows messages related to offline transaction -export const debugOffline = debug && true; +export const debugOffline = debug && false; export const debugGridSettings = debug && false; From 8585871f534c14d04390743c7e11d0dcd189dfef Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:14:17 +0800 Subject: [PATCH 05/15] fix TS tests --- .../quadraticCore/worker/offline.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.test.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.test.ts index a140bc878f..92fd5965ef 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.test.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.test.ts @@ -32,11 +32,12 @@ describe('offline', () => { offline.addUnsentTransaction('3', 'c', 1); const load = await offline.load(); expect(load?.length).toBe(3); - expect(load).toStrictEqual([ - { transactionId: '1', transactions: 'a', operations: 1 }, - { transactionId: '2', transactions: 'b', operations: 1 }, - { transactionId: '3', transactions: 'c', operations: 1 }, - ]); + expect(load![0].transactionId).toBe('1'); + expect(load![0].transactions).toBe('a'); + expect(load![1].transactionId).toBe('2'); + expect(load![1].transactions).toBe('b'); + expect(load![2].transactionId).toBe('3'); + expect(load![2].transactions).toBe('c'); }); it('marks offline transactions as complete', async () => { @@ -47,10 +48,10 @@ describe('offline', () => { const load = await offline.load(); expect(load?.length).toBe(2); - expect(await offline.load()).toStrictEqual([ - { transactionId: '1', transactions: 'a', operations: 1 }, - { transactionId: '3', transactions: 'c', operations: 1 }, - ]); + expect(load![0].transactionId).toBe('1'); + expect(load![0].transactions).toBe('a'); + expect(load![1].transactionId).toBe('3'); + expect(load![1].transactions).toBe('c'); }); it('checks whether there are any unsent transactions in db', async () => { From 9a2418d50acb450b4f8085745337cfa87b206b79 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:33:24 +0800 Subject: [PATCH 06/15] always refresh JWT token whenever mp tries to connect --- .../multiplayerWebWorker/multiplayer.ts | 7 +++++- .../multiplayerClientMessages.ts | 16 +++++++++++-- .../worker/multiplayerClient.ts | 24 +++++++++++++++++++ .../worker/multiplayerServer.ts | 4 +++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts index 1a9a92ac04..35a603ec91 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts @@ -93,7 +93,7 @@ export class Multiplayer { if (this.codeRunning) this.sendCodeRunning(codeRunning); }; - private handleMessage = (e: MessageEvent) => { + private handleMessage = async (e: MessageEvent) => { if (debugWebWorkersMessages) console.log(`[Multiplayer] message: ${e.data.type}`); switch (e.data.type) { @@ -117,6 +117,11 @@ export class Multiplayer { events.emit('needRefresh', 'force'); break; + case 'multiplayerClientRefreshJwt': + await this.addJwtCookie(true); + this.send({ type: 'clientMultiplayerRefreshJwt', id: e.data.id }); + break; + default: console.warn('Unhandled message type', e.data); } diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayerClientMessages.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayerClientMessages.ts index 4fbe2affc2..16eba887a3 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayerClientMessages.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayerClientMessages.ts @@ -84,12 +84,23 @@ export interface MultiplayerClientReload { type: 'multiplayerClientReload'; } +export interface MultiplayerClientRefreshJwt { + type: 'multiplayerClientRefreshJwt'; + id: number; +} + +export interface ClientMultiplayerRefreshJwt { + type: 'clientMultiplayerRefreshJwt'; + id: number; +} + export type MultiplayerClientMessage = | MultiplayerClientState | MultiplayerClientUserUpdate | MultiplayerClientUsersInRoom | MultiplayerClientUserUpdate - | MultiplayerClientReload; + | MultiplayerClientReload + | MultiplayerClientRefreshJwt; export type ClientMultiplayerMessage = | ClientMultiplayerInit @@ -99,4 +110,5 @@ export type ClientMultiplayerMessage = | ClientMultiplayerCellEdit | clientMultiplayerViewport | clientMultiplayerCodeRunning - | ClientMultiplayerFollow; + | ClientMultiplayerFollow + | ClientMultiplayerRefreshJwt; diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerClient.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerClient.ts index 492d400b71..875549881a 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerClient.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerClient.ts @@ -11,6 +11,10 @@ import { cellEditDefault, multiplayerServer } from './multiplayerServer'; declare var self: WorkerGlobalScope & typeof globalThis; class MultiplayerClient { + // messages pending a reconnect + private waitingForConnection: Record = {}; + private id = 0; + constructor() { self.onmessage = this.handleMessage; } @@ -59,6 +63,15 @@ class MultiplayerClient { multiplayerServer.userUpdate.follow = e.data.follow; break; + case 'clientMultiplayerRefreshJwt': + if (e.data.id in this.waitingForConnection) { + this.waitingForConnection[e.data.id](); + delete this.waitingForConnection[e.data.id]; + } else { + throw new Error('Expected id to be in waitingForConnection for clientMultiplayerRefreshJwt'); + } + break; + default: console.warn('[multiplayerClient] Unhandled message type', e.data); } @@ -92,6 +105,17 @@ class MultiplayerClient { type: 'multiplayerClientReload', }); } + + sendRefreshJwt(): Promise { + return new Promise((resolve) => { + const id = this.id++; + this.waitingForConnection[id] = resolve; + this.send({ + type: 'multiplayerClientRefreshJwt', + id, + }); + }); + } } export const multiplayerClient = new MultiplayerClient(); diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts index 178495c8c1..65489f8a67 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts @@ -110,12 +110,14 @@ export class MultiplayerServer { multiplayerClient.sendState(state); } - private connect() { + private async connect() { if (this.state === 'connecting' || this.state === 'waiting to reconnect') { return; } this.state = 'connecting'; + await multiplayerClient.sendRefreshJwt(); + this.websocket = new WebSocket(import.meta.env.VITE_QUADRATIC_MULTIPLAYER_URL); this.websocket.addEventListener('message', this.handleMessage); From 143aed4f607fb07a94e392dddc53ce626fbd22c2 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:37:31 +0800 Subject: [PATCH 07/15] skip jwt refresh on initial connection --- .../multiplayerWebWorker/worker/multiplayerServer.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts index 65489f8a67..3e9a1c68a6 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts @@ -89,7 +89,7 @@ export class MultiplayerServer { x: message.x, y: message.y, }; - this.connect(); + this.connect(true); self.addEventListener('online', () => { if (this.state === 'no internet') { @@ -110,13 +110,18 @@ export class MultiplayerServer { multiplayerClient.sendState(state); } - private async connect() { + // Attempt to connect to the server. + // @param skipFetchJwt If true, don't fetch a new JWT from the client + // (only true on first connection, since client already provided the jwt) + private async connect(skipFetchJwt = false) { if (this.state === 'connecting' || this.state === 'waiting to reconnect') { return; } this.state = 'connecting'; - await multiplayerClient.sendRefreshJwt(); + if (!skipFetchJwt) { + await multiplayerClient.sendRefreshJwt(); + } this.websocket = new WebSocket(import.meta.env.VITE_QUADRATIC_MULTIPLAYER_URL); this.websocket.addEventListener('message', this.handleMessage); From 002919ba38b54d794dcd4d2cb96beb943c9d865e Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:37:36 +0800 Subject: [PATCH 08/15] prettier + remove console.log --- quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx | 2 +- .../src/app/web-workers/quadraticCore/worker/offline.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index 4e6ca8b6dc..9fa024f9ec 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -40,7 +40,7 @@ export default function SyncState() { const offlineTransactionsApplied = (timestamps: number[]) => { if (timestamps.length === 0) return; - const to = timeAgo(timestamps[timestamps.length - 1]) + const to = timeAgo(timestamps[timestamps.length - 1]); const message = `We applied ${timestamps.length} unsynced changes from ${to}. You can undo these changes.`; addGlobalSnackbar(message, { severity: 'warning' }); }; diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts index 71a057dfc2..91933d0e91 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/offline.ts @@ -168,7 +168,6 @@ class Offline { // Loads unsent transactions and applies them to the grid. This is called twice: once after the grid and pixi loads; // and a second time when the socket server connects. async loadTransactions() { - console.log('loadTransaction'); const unsentTransactions = await this.load(); if (debugOffline) { if (unsentTransactions?.length) { From a9a25629ebf5ca850dded186dc581e1eb1f79d12 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 10:47:43 +0800 Subject: [PATCH 09/15] add back red message for broken connections --- .../app/web-workers/multiplayerWebWorker/multiplayer.ts | 3 +++ .../multiplayerWebWorker/worker/multiplayerServer.ts | 7 +------ 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts index 35a603ec91..8a8974f604 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/multiplayer.ts @@ -101,6 +101,9 @@ export class Multiplayer { this.state = e.data.state; if (this.state === 'no internet' || this.state === 'waiting to reconnect') { this.clearAllUsers(); + this.brokenConnection = true; + } else if (this.state === 'connected') { + this.brokenConnection = false; } events.emit('multiplayerState', this.state); break; diff --git a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts index 3e9a1c68a6..8a07dcd0d0 100644 --- a/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts +++ b/quadratic-client/src/app/web-workers/multiplayerWebWorker/worker/multiplayerServer.ts @@ -56,10 +56,8 @@ export class MultiplayerServer { private sessionId?: string; private fileId?: string; private user?: User; - private anonymous?: boolean; private connectionTimeout: number | undefined; - private brokenConnection = false; private userData?: UserData; @@ -78,7 +76,7 @@ export class MultiplayerServer { this.sessionId = message.sessionId; this.fileId = message.fileId; this.user = message.user; - this.anonymous = message.anonymous; + this.userData = { sheetId: message.sheetId, selection: message.selection, @@ -128,19 +126,16 @@ export class MultiplayerServer { this.websocket.addEventListener('close', () => { if (debugShowMultiplayer) console.log('[Multiplayer] websocket closed unexpectedly.'); - this.brokenConnection = true; this.state = 'waiting to reconnect'; this.reconnect(); }); this.websocket.addEventListener('error', (e) => { if (debugShowMultiplayer) console.log('[Multiplayer] websocket error', e); - this.brokenConnection = true; this.state = 'waiting to reconnect'; this.reconnect(); }); this.websocket.addEventListener('open', () => { if (debugShow) console.log('[Multiplayer] websocket connected.'); - this.brokenConnection = false; this.state = 'connected'; this.enterFileRoom(); this.waitingForConnection.forEach((resolve) => resolve(0)); From e1a5a10c20efe4d18caa867672abd9074e45ba2c Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 11:05:14 +0800 Subject: [PATCH 10/15] add snackbar message for connection/lost connection --- .../src/app/ui/menus/BottomBar/SyncState.tsx | 29 +++++++++++++++++-- .../components/GlobalSnackbarProvider.tsx | 2 +- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index 9fa024f9ec..7fe439e669 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -14,22 +14,45 @@ import { import { timeAgo } from '@/shared/utils/timeAgo'; import { Check, ErrorOutline } from '@mui/icons-material'; import { CircularProgress, Tooltip, useTheme } from '@mui/material'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import BottomBarItem from './BottomBarItem'; +const TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE = 1000; + export default function SyncState() { const theme = useTheme(); const [syncState, setSyncState] = useState(multiplayer.state); const { addGlobalSnackbar } = useGlobalSnackbar(); + const [disconnectMessage, setDisconnectMessage] = useState(false); + const timeout = useRef(null); useEffect(() => { - const updateState = (state: MultiplayerState) => setSyncState(state); + const updateState = (state: MultiplayerState) => { + if (state === 'waiting to reconnect' || state === 'no internet') { + if (!timeout.current && !disconnectMessage) { + timeout.current = window.setTimeout(() => { + addGlobalSnackbar('Connection to the Quadratic server was lost. Your changes are only saved locally.', { severity: 'error' }); + timeout.current = null; + setDisconnectMessage(true); + }, TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE); + } + } + if (state === 'connected' && timeout.current) { + window.clearTimeout(timeout.current); + timeout.current = null; + } + if (state === 'connected' && disconnectMessage) { + setDisconnectMessage(false); + addGlobalSnackbar('Connection to the Quadratic server was reestablished.', { severity: 'success'}) + } + setSyncState(state); + }; events.on('multiplayerState', updateState); return () => { events.off('multiplayerState', updateState); }; - }, []); + }, [addGlobalSnackbar, disconnectMessage]); const [unsavedTransactions, setUnsavedTransactions] = useState(0); useEffect(() => { diff --git a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx index bbe495e317..41cd27dbb2 100644 --- a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx +++ b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx @@ -12,7 +12,7 @@ export const snackbarSeverityQueryParam = 'snackbar-severity'; */ export interface GlobalSnackbar { - addGlobalSnackbar: (message: string, options?: { severity?: 'error' | 'warning' }) => void; + addGlobalSnackbar: (message: string, options?: { severity?: 'error' | 'warning' | 'success' }) => void; } const defaultContext: GlobalSnackbar = { addGlobalSnackbar: () => { From a5afe33e3f157362b589d734047ee236967b2635 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sat, 13 Jul 2024 11:09:07 +0800 Subject: [PATCH 11/15] prettier --- quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index 7fe439e669..b67f013dec 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -32,7 +32,9 @@ export default function SyncState() { if (state === 'waiting to reconnect' || state === 'no internet') { if (!timeout.current && !disconnectMessage) { timeout.current = window.setTimeout(() => { - addGlobalSnackbar('Connection to the Quadratic server was lost. Your changes are only saved locally.', { severity: 'error' }); + addGlobalSnackbar('Connection to the Quadratic server was lost. Your changes are only saved locally.', { + severity: 'error', + }); timeout.current = null; setDisconnectMessage(true); }, TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE); @@ -44,7 +46,7 @@ export default function SyncState() { } if (state === 'connected' && disconnectMessage) { setDisconnectMessage(false); - addGlobalSnackbar('Connection to the Quadratic server was reestablished.', { severity: 'success'}) + addGlobalSnackbar('Connection to the Quadratic server was reestablished.', { severity: 'success' }); } setSyncState(state); }; From a4c6499eb499fb155d67c91f6082dd8999cea9f3 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sun, 14 Jul 2024 05:24:29 +0800 Subject: [PATCH 12/15] added refresh button to snackbar --- .../src/app/ui/menus/BottomBar/SyncState.tsx | 3 +- .../components/GlobalSnackbarProvider.tsx | 46 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index b67f013dec..26eb11ded4 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -33,7 +33,8 @@ export default function SyncState() { if (!timeout.current && !disconnectMessage) { timeout.current = window.setTimeout(() => { addGlobalSnackbar('Connection to the Quadratic server was lost. Your changes are only saved locally.', { - severity: 'error', + severity: 'warning', + button: { title: 'Refresh', callback: () => window.location.reload() }, }); timeout.current = null; setDisconnectMessage(true); diff --git a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx index 41cd27dbb2..4fe2053e68 100644 --- a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx +++ b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx @@ -2,6 +2,7 @@ import CloseIcon from '@mui/icons-material/Close'; import { Alert, AlertColor, IconButton, Snackbar } from '@mui/material'; import * as React from 'react'; import { useSearchParams } from 'react-router-dom'; +import { Button } from '../shadcn/ui/button'; const DURATION = 6000; export const snackbarMsgQueryParam = 'snackbar-msg'; @@ -11,8 +12,13 @@ export const snackbarSeverityQueryParam = 'snackbar-severity'; * Context */ +interface SnackbarOptions { + severity?: 'error' | 'warning' | 'success'; + button?: { title: string; callback: Function }; +} + export interface GlobalSnackbar { - addGlobalSnackbar: (message: string, options?: { severity?: 'error' | 'warning' | 'success' }) => void; + addGlobalSnackbar: (message: string, options?: SnackbarOptions) => void; } const defaultContext: GlobalSnackbar = { addGlobalSnackbar: () => { @@ -36,8 +42,8 @@ export const useGlobalSnackbar: () => GlobalSnackbar = () => React.useContext(Gl interface Message { key: number; message: string; - // snackbarProps: SnackbarProps; severity?: AlertColor; + button?: { title: string; callback: Function }; stayOpen?: boolean; } @@ -68,27 +74,21 @@ export function GlobalSnackbarProvider({ children }: { children: React.ReactElem * Example: `showSnackbar("Copied as PNG")` * Example: `showSnackbar("My message here", { severity: 'error' }) * - * Future: customize the snackbar by passing your own props to override the defaults: - * Example: `showSnackbar("Thing completed", { action: })` + * Can add a button to the snackbar by passing options { button: { title: string, callback: Function } } */ - const addGlobalSnackbar: GlobalSnackbar['addGlobalSnackbar'] = React.useCallback((message, options = {}) => { - if (typeof message === 'string') { + const addGlobalSnackbar: GlobalSnackbar['addGlobalSnackbar'] = React.useCallback( + (message: string, options?: SnackbarOptions) => { setMessageQueue((prev) => [ ...prev, { message, key: new Date().getTime(), - ...(options.severity ? { severity: options.severity } : {}), + ...(options || {}), }, ]); - } - // else if (arg && (arg.children || arg.message || arg.action)) { - // Handle customization - // } - else { - throw new Error('Unexpected arguments to `addGlobalSnackbar`'); - } - }, []); + }, + [] + ); const handleClose = (event: React.SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { @@ -109,7 +109,14 @@ export function GlobalSnackbarProvider({ children }: { children: React.ReactElem ? { children: ( - {activeMessage.message} +
+ {activeMessage.message} + {activeMessage?.button && ( + + )} +
), } @@ -140,14 +147,11 @@ export function GlobalSnackbarProvider({ children }: { children: React.ReactElem TransitionProps={{ onExited: handleExited }} {...otherProps} action={ - - {/* if activeMessage.snackbarProps.action */} + <> - + } /> From 649eb23d9be17caff55471508039d2597a9f5a13 Mon Sep 17 00:00:00 2001 From: David Figatner Date: Sun, 14 Jul 2024 05:38:01 +0800 Subject: [PATCH 13/15] add undo button to snackbar for offline sync --- .../src/app/ui/menus/BottomBar/SyncState.tsx | 13 +++++- .../components/GlobalSnackbarProvider.tsx | 41 ++++++++----------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx index 26eb11ded4..f1da778bb2 100644 --- a/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx +++ b/quadratic-client/src/app/ui/menus/BottomBar/SyncState.tsx @@ -16,6 +16,7 @@ import { Check, ErrorOutline } from '@mui/icons-material'; import { CircularProgress, Tooltip, useTheme } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; import BottomBarItem from './BottomBarItem'; +import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore'; const TIMEOUT_TO_SHOW_DISCONNECT_MESSAGE = 1000; @@ -68,7 +69,17 @@ export default function SyncState() { if (timestamps.length === 0) return; const to = timeAgo(timestamps[timestamps.length - 1]); const message = `We applied ${timestamps.length} unsynced changes from ${to}. You can undo these changes.`; - addGlobalSnackbar(message, { severity: 'warning' }); + addGlobalSnackbar(message, { + severity: 'warning', + button: { + title: 'Undo', + callback: () => { + for (let i = 0; i < timestamps.length; i++) { + quadraticCore.undo(); + } + }, + }, + }); }; events.on('offlineTransactionsApplied', offlineTransactionsApplied); diff --git a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx index 4fe2053e68..82092190ed 100644 --- a/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx +++ b/quadratic-client/src/shared/components/GlobalSnackbarProvider.tsx @@ -1,6 +1,6 @@ -import CloseIcon from '@mui/icons-material/Close'; -import { Alert, AlertColor, IconButton, Snackbar } from '@mui/material'; -import * as React from 'react'; +// import CloseIcon from '@mui/icons-material/Close'; +import { Alert, AlertColor, Snackbar } from '@mui/material'; +import { useEffect, createContext, useContext, useState, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Button } from '../shadcn/ui/button'; @@ -27,13 +27,13 @@ const defaultContext: GlobalSnackbar = { ); }, }; -export const GlobalSnackbarContext = React.createContext(defaultContext); +export const GlobalSnackbarContext = createContext(defaultContext); /** * Consumer */ -export const useGlobalSnackbar: () => GlobalSnackbar = () => React.useContext(GlobalSnackbarContext); +export const useGlobalSnackbar: () => GlobalSnackbar = () => useContext(GlobalSnackbarContext); /** * Provider @@ -48,23 +48,25 @@ interface Message { } export function GlobalSnackbarProvider({ children }: { children: React.ReactElement }) { - const [messageQueue, setMessageQueue] = React.useState([]); - const [open, setOpen] = React.useState(false); - const [stayOpen, setStayOpen] = React.useState(false); - const [activeMessage, setActiveMessage] = React.useState(undefined); + const [messageQueue, setMessageQueue] = useState([]); + const [open, setOpen] = useState(false); + const [stayOpen, setStayOpen] = useState(false); + const [activeMessage, setActiveMessage] = useState(undefined); const [searchParams, setSearchParams] = useSearchParams(); - React.useEffect(() => { + useEffect(() => { if (messageQueue.length && !activeMessage) { // Set a new snack when we don't have an active one setActiveMessage({ ...messageQueue[0] }); setMessageQueue((prev) => prev.slice(1)); setStayOpen(!!messageQueue[0].stayOpen); setOpen(true); - } else if (messageQueue.length && activeMessage && open) { - // Close an active snack when a new one is added - setOpen(false); } + + // we don't want a new message to replace the current message until the timer expires + // else if (messageQueue.length && activeMessage && open) { + // setOpen(false); + // } }, [messageQueue, activeMessage, open]); /* @@ -76,7 +78,7 @@ export function GlobalSnackbarProvider({ children }: { children: React.ReactElem * * Can add a button to the snackbar by passing options { button: { title: string, callback: Function } } */ - const addGlobalSnackbar: GlobalSnackbar['addGlobalSnackbar'] = React.useCallback( + const addGlobalSnackbar: GlobalSnackbar['addGlobalSnackbar'] = useCallback( (message: string, options?: SnackbarOptions) => { setMessageQueue((prev) => [ ...prev, @@ -109,7 +111,7 @@ export function GlobalSnackbarProvider({ children }: { children: React.ReactElem ? { children: ( -
+
{activeMessage.message} {activeMessage?.button && (