From 9982c96b6383fd505576aed929f834e79d30da50 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 4 Jun 2026 13:50:40 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=86=99=20Bug=20fixed=20in=20localstorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/chat-history/useChatHistory.ts | 7 +- src/hooks/rooms/widgets/useChatWidget.ts | 40 ++++----- src/hooks/useLocalStorage.ts | 108 ++++++++++++++++++++++- 3 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/hooks/chat-history/useChatHistory.ts b/src/hooks/chat-history/useChatHistory.ts index 2cd021f..e935296 100644 --- a/src/hooks/chat-history/useChatHistory.ts +++ b/src/hooks/chat-history/useChatHistory.ts @@ -12,11 +12,14 @@ const MESSENGER_HISTORY_MAX = 1000; let CHAT_HISTORY_COUNTER: number = 0; let MESSENGER_HISTORY_COUNTER: number = 0; +const slimChatEntriesForStorage = (entries: IChatEntry[]): IChatEntry[] => + entries.map(entry => entry.imageUrl ? { ...entry, imageUrl: undefined } : entry); + const useChatHistoryState = () => { - const [ chatHistory, setChatHistory ] = useLocalStorage('chatHistory', []); + const [ chatHistory, setChatHistory ] = useLocalStorage('chatHistory', [], { toStorage: slimChatEntriesForStorage }); const [ roomHistory, setRoomHistory ] = useLocalStorage('roomHistory', []); - const [ messengerHistory, setMessengerHistory ] = useLocalStorage('messengerHistory', []); + const [ messengerHistory, setMessengerHistory ] = useLocalStorage('messengerHistory', [], { toStorage: slimChatEntriesForStorage }); const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false); const addChatEntry = (entry: IChatEntry) => diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index e05fd36..ea61c6f 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -23,11 +23,6 @@ const useChatWidgetState = () => const { addChatEntry, updateChatEntry } = useChatHistory(); const { settings, translateIncoming, consumeOutgoingTranslation } = useTranslation(); const isDisposed = useRef(false); - // Reactive: re-renders if the session-data snapshot flips (e.g. - // reconnect under a different user id). Safe to call here — - // useChatWidget is NOT wrapped in useBetween (see export below), - // so the real React dispatcher is in scope and - // useSyncExternalStore installs correctly. const ownUserId = (useUserDataSnapshot().userId || -1); const applyTranslationToBubble = useCallback((chatMessage: ChatBubbleMessage, originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) => @@ -230,22 +225,25 @@ const useChatWidgetState = () => return newValue; }); - const chatEntryId = addChatEntry({ - id: -1, - webId: userData.webID, - entityId: userData.roomIndex, - name: username, - imageUrl, - style: styleId, - chatType: chatType, - entityType: userData.type, - message: formattedText, - timestamp: ChatHistoryCurrentDate(), - type: ChatEntryType.TYPE_CHAT, - roomId: roomSession.roomId, - color, - ...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {}) - }); + + const chatEntryId = (userType === RoomObjectType.USER) + ? addChatEntry({ + id: -1, + webId: userData.webID, + entityId: userData.roomIndex, + name: username, + imageUrl, + style: styleId, + chatType: chatType, + entityType: userData.type, + message: formattedText, + timestamp: ChatHistoryCurrentDate(), + type: ChatEntryType.TYPE_CHAT, + roomId: roomSession.roomId, + color, + ...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {}) + }) + : -1; if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index cd73ced..1e760c8 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -1,10 +1,36 @@ import { NitroLogger } from '@nitrots/nitro-renderer'; -import { Dispatch, SetStateAction, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import { GetLocalStorage, SetLocalStorage } from '../api'; const userId = new URLSearchParams(window.location.search).get('userid') || 0; -const useLocalStorageState = (key: string, initialValue: T): [ T, Dispatch>] => +const STORAGE_WRITE_DEBOUNCE_MS = 250; +const QUOTA_TRIM_FACTOR = 0.5; // on quota error, keep the newest 50%. +const MIN_RETAINED_ENTRIES = 50; + +const isQuotaError = (error: unknown): boolean => +{ + if(!error || typeof error !== 'object') return false; + const name = (error as { name?: string }).name; + if(name === 'QuotaExceededError') return true; + if(name === 'NS_ERROR_DOM_QUOTA_REACHED') return true; + return false; +}; + +const trimArrayForQuota = (value: T): T => +{ + if(!Array.isArray(value)) return value; + if(value.length <= MIN_RETAINED_ENTRIES) return [] as unknown as T; + const keep = Math.max(MIN_RETAINED_ENTRIES, Math.floor(value.length * QUOTA_TRIM_FACTOR)); + return value.slice(value.length - keep) as unknown as T; +}; + +interface UseLocalStorageOptions +{ + toStorage?: (value: T) => unknown; +} + +const useLocalStorageState = (key: string, initialValue: T, options: UseLocalStorageOptions = {}): [ T, Dispatch>] => { key = userId ? `${ key }.${ userId }` : key; @@ -22,6 +48,82 @@ const useLocalStorageState = (key: string, initialValue: T): [ T, Dispatch(null); + const writeTimerRef = useRef | null>(null); + const optionsRef = useRef(options); + + optionsRef.current = options; + + const flushWrite = (value: T) => + { + if(typeof window === 'undefined') return; + + const project = optionsRef.current.toStorage; + const projected = project ? project(value) : value; + + try + { + SetLocalStorage(key, projected); + return; + } + catch(error) + { + if(!isQuotaError(error)) + { + NitroLogger.error(error); + return; + } + } + + try + { + const trimmed = trimArrayForQuota(projected as T); + SetLocalStorage(key, trimmed); + NitroLogger.warn(`[useLocalStorage] quota exceeded for ${ key }, trimmed payload`); + } + catch(retryError) + { + NitroLogger.error(retryError); + try { window.localStorage.removeItem(key); } catch(_) { } + } + }; + + const scheduleWrite = (value: T) => + { + pendingWriteRef.current = value; + if(writeTimerRef.current) clearTimeout(writeTimerRef.current); + writeTimerRef.current = setTimeout(() => + { + writeTimerRef.current = null; + if(pendingWriteRef.current !== null) + { + flushWrite(pendingWriteRef.current); + pendingWriteRef.current = null; + } + }, STORAGE_WRITE_DEBOUNCE_MS); + }; + + useEffect(() => + { + const flushOnLeave = () => + { + if(pendingWriteRef.current === null) return; + if(writeTimerRef.current) clearTimeout(writeTimerRef.current); + writeTimerRef.current = null; + flushWrite(pendingWriteRef.current); + pendingWriteRef.current = null; + }; + + window.addEventListener('pagehide', flushOnLeave); + window.addEventListener('beforeunload', flushOnLeave); + + return () => + { + window.removeEventListener('pagehide', flushOnLeave); + window.removeEventListener('beforeunload', flushOnLeave); + }; + }, []); + const setValue = (value: T) => { try @@ -30,7 +132,7 @@ const useLocalStorageState = (key: string, initialValue: T): [ T, Dispatch