Revert "🆙 Bug fixed in localstorage"

This reverts commit 47453db5ee.
This commit is contained in:
duckietm
2026-06-04 13:43:29 +02:00
parent 47453db5ee
commit 59ed27b727
17 changed files with 210 additions and 368 deletions
+28 -3
View File
@@ -1,4 +1,4 @@
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetConfiguration, GetRoomContentLoader, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomEngineObjectPlacedEvent, RoomObjectPlacementSource, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { BuilderFurniPlaceableStatus, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api';
@@ -89,6 +89,27 @@ const useCatalogStore = () =>
setCurrentType(normalizeCatalogType(type));
}, []);
// Real-time furni importati: ri-mergia il chunk custom/imported.json5 nelle Map
// furnidata + RoomContentLoader all'apertura del catalogo, SENZA reload del client.
const refreshImportedFurnidata = useCallback(() =>
{
try
{
const base = GetConfiguration().getValue<string>('furnidata.url');
if(!base || !base.length) return;
const importedUrl = base.replace(/\/+$/, '') + '/custom/imported.json5';
GetSessionDataManager().mergeFurnitureDataFromUrl(importedUrl).then(added =>
{
if(added && added.length) GetRoomContentLoader().processFurnitureData(added);
}).catch(() => {});
}
catch
{}
}, []);
const openCatalogByType = useCallback((type?: string) =>
{
const catalogType = normalizeCatalogType(type);
@@ -98,8 +119,10 @@ const useCatalogStore = () =>
resetVisibleCatalogState(catalogType);
}
refreshImportedFurnidata();
setIsVisible(true);
}, [ currentType, resetVisibleCatalogState ]);
}, [ currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
const toggleCatalogByType = useCallback((type?: string) =>
{
@@ -117,8 +140,10 @@ const useCatalogStore = () =>
resetVisibleCatalogState(catalogType);
}
refreshImportedFurnidata();
setIsVisible(true);
}, [ isVisible, currentType, resetVisibleCatalogState ]);
}, [ isVisible, currentType, resetVisibleCatalogState, refreshImportedFurnidata ]);
const getBuilderFurniPlaceableStatus = useCallback((offer: IPurchasableOffer) =>
{
+2 -18
View File
@@ -12,27 +12,11 @@ const MESSENGER_HISTORY_MAX = 1000;
let CHAT_HISTORY_COUNTER: number = 0;
let MESSENGER_HISTORY_COUNTER: number = 0;
/**
* Project a list of chat entries to the slim shape we want to persist in
* localStorage. `imageUrl` is a base64 data URL of the avatar / pet head
* (10-50 KB each) - keeping it in storage blows past the browser quota
* inside minutes in a pet-heavy room. The avatar can always be re-rendered
* from `look` via ChatBubbleUtilities.getUserImage(), and pet images are
* regenerated from the bubble flow when needed; we just don't restore
* head thumbnails for entries loaded from a previous session.
*
* `style` / `chatType` / `color` are kept because they're tiny but
* meaningful for re-rendering the bubble. Translation fields are kept
* because they're already text.
*/
const slimChatEntriesForStorage = (entries: IChatEntry[]): IChatEntry[] =>
entries.map(entry => entry.imageUrl ? { ...entry, imageUrl: undefined } : entry);
const useChatHistoryState = () =>
{
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', [], { toStorage: slimChatEntriesForStorage });
const [ chatHistory, setChatHistory ] = useLocalStorage<IChatEntry[]>('chatHistory', []);
const [ roomHistory, setRoomHistory ] = useLocalStorage<IRoomHistoryEntry[]>('roomHistory', []);
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', [], { toStorage: slimChatEntriesForStorage });
const [ messengerHistory, setMessengerHistory ] = useLocalStorage<IChatEntry[]>('messengerHistory', []);
const [ needsRoomInsert, setNeedsRoomInsert ] = useLocalStorage('needsRoomInsert', false);
const addChatEntry = (entry: IChatEntry) =>
@@ -40,19 +40,4 @@ describe('mentionsStore', () =>
expect(getUnreadCount()).toBe(1);
expect(getMentionsSnapshot().find(m => m.mentionId === 1)!.read).toBe(true);
});
it('drops mentions with non-positive id (defensive against id=0 spam)', () =>
{
addMention(make(0));
addMention(make(-1));
expect(getMentionsSnapshot()).toHaveLength(0);
});
it('dedupes duplicate ids even after the legacy id !== 0 carve-out is gone', () =>
{
addMention(make(7));
addMention(make(7));
addMention(make(7));
expect(getMentionsSnapshot()).toHaveLength(1);
});
});
+1
View File
@@ -1,2 +1,3 @@
export * from './useMentionsSnapshot';
export * from './useMentionMessages';
export * from './useMentionAutocomplete';
+3 -21
View File
@@ -1,21 +1,10 @@
import { IMentionEntry } from '../../api/mentions';
// Hard cap on how many mentions we hold in memory at once. The server's
// initial list is capped (mentions.store.limit, default 50) but live
// MentionReceived packets feed into addMention unbounded - so a server bug
// or a hostile/injected stream could otherwise grow the array and the DOM
// forever. 200 is comfortably more than any realistic active user has and
// well below anything that would inflate memory.
const MAX_MENTIONS = 200;
let mentions: IMentionEntry[] = [];
const listeners = new Set<() => void>();
const emit = () => { for(const l of listeners) l(); };
const cap = (list: IMentionEntry[]): IMentionEntry[] =>
(list.length > MAX_MENTIONS) ? list.slice(0, MAX_MENTIONS) : list;
export const subscribeMentions = (onChange: () => void): (() => void) =>
{
listeners.add(onChange);
@@ -28,21 +17,14 @@ export const getUnreadCount = (): number => mentions.reduce((n, m) => n + (m.rea
export const setMentions = (list: IMentionEntry[]): void =>
{
mentions = cap([...list].sort((a, b) => b.mentionId - a.mentionId));
mentions = [...list].sort((a, b) => b.mentionId - a.mentionId);
emit();
};
export const addMention = (entry: IMentionEntry): void =>
{
// Drop entries the server failed to persist (generatedId 0 / negative).
// The server hardening already refuses to push these, but the client
// stays defensive in case a stale gameserver or an injected packet sends
// one - without this guard, the old "id !== 0" dedup carve-out let
// every duplicate through.
if(!entry || !Number.isFinite(entry.mentionId) || entry.mentionId <= 0) return;
if(mentions.some(m => m.mentionId === entry.mentionId)) return;
mentions = cap([entry, ...mentions]);
if(mentions.some(m => m.mentionId === entry.mentionId && entry.mentionId !== 0)) return;
mentions = [entry, ...mentions];
emit();
};
+8 -42
View File
@@ -1,29 +1,15 @@
import { MentionReceivedEvent, MentionsListEvent, RequestMentionsComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef } from 'react';
import { GetConfigurationValue, IMentionEntry, LocalizeText, NotificationBubbleType, PlaySound, SendMessageComposer } from '../../api';
import { useCallback, useEffect } from 'react';
import { GetConfigurationValue, IMentionEntry, PlaySound, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNotificationActions } from '../notification';
import { addMention, setMentions } from './mentionsStore';
import { pushMentionToast } from './mentionToastsStore';
// Dedicated mention chime served from nitro-assets/sounds/<sample>.mp3.
const MENTION_SOUND_SAMPLE = 'mentions_notification';
// Floor on the gap between bubble/chime notifications. Even if the server
// (or an injected packet stream) pushes mentions faster than this, the user
// gets at most one chime + bubble per window. The mentions list itself
// still updates in real time - this only throttles the in-screen feedback.
const NOTIFICATION_THROTTLE_MS = 1500;
// Drop any single mention packet whose mention id we've already seen this
// session, so a replay attack can't re-trigger the bubble + sound even if
// the client store dropped the entry already.
const SEEN_IDS_MAX = 500;
export const useMentionMessages = (): void =>
{
const { showSingleBubble } = useNotificationActions();
const lastNotificationRef = useRef<number>(0);
const seenIdsRef = useRef<Set<number>>(new Set());
const onMentionsList = useCallback((event: MentionsListEvent) =>
{
const list = event.getParser().mentions;
@@ -32,7 +18,7 @@ export const useMentionMessages = (): void =>
mentionId: m.mentionId,
senderId: m.senderId,
senderUsername: m.senderUsername,
senderFigure: m.senderFigure ?? '',
senderFigure: m.senderFigure,
roomId: m.roomId,
roomName: m.roomName,
message: m.message,
@@ -48,22 +34,11 @@ export const useMentionMessages = (): void =>
const m = event.getParser().mention;
if(!m || !Number.isFinite(m.mentionId) || m.mentionId <= 0) return;
const seen = seenIdsRef.current;
if(seen.has(m.mentionId)) return;
seen.add(m.mentionId);
if(seen.size > SEEN_IDS_MAX)
{
const first = seen.values().next().value as number | undefined;
if(first !== undefined) seen.delete(first);
}
const entry: IMentionEntry = {
mentionId: m.mentionId,
senderId: m.senderId,
senderUsername: m.senderUsername,
senderFigure: m.senderFigure ?? '',
senderFigure: m.senderFigure,
roomId: m.roomId,
roomName: m.roomName,
message: m.message,
@@ -74,20 +49,11 @@ export const useMentionMessages = (): void =>
addMention(entry);
const now = Date.now();
if((now - lastNotificationRef.current) < NOTIFICATION_THROTTLE_MS) return;
lastNotificationRef.current = now;
if(GetConfigurationValue<boolean>('mentions_ui.sound', true)) PlaySound(MENTION_SOUND_SAMPLE);
showSingleBubble(
LocalizeText('mentions.notification', [ 'sender', 'room' ], [ entry.senderUsername, entry.roomName ]),
NotificationBubbleType.INFO,
null,
'mentions/toggle',
entry.senderUsername
);
}, [ showSingleBubble ]);
// Notifica laterale custom (avatar + messaggio + dismiss) invece del bubble generico.
pushMentionToast(entry);
}, []);
useMessageEvent<MentionsListEvent>(MentionsListEvent, onMentionsList);
useMessageEvent<MentionReceivedEvent>(MentionReceivedEvent, onMentionReceived);
+1 -1
View File
@@ -64,6 +64,6 @@ export const useNavigatorUiStore = createNitroStore<NavigatorUiState & Navigator
markInitDone: () => set({ needsInit: false }),
requestSearch: () => set({ needsSearch: true }),
consumeSearchRequest: () => set({ needsSearch: false }),
setTab: (code) => set({ currentTabCode: code, currentFilter: '' }),
setTab: (code) => set({ currentTabCode: code, currentFilter: '', isCreatorOpen: false }),
setFilter: (value) => set({ currentFilter: value })
}));
+16 -25
View File
@@ -230,31 +230,22 @@ const useChatWidgetState = () =>
return newValue;
});
// Pet, Bot and Rentable Bot chat is fire-and-forget ("UDP-style"):
// the live bubble already rendered above, but we deliberately skip
// addChatEntry so the entry never lands in localStorage. A pet-heavy
// room used to push 30+ KB per message (base64 head data URL) into
// the chat history, exhausting the localStorage quota in minutes.
// Real users still go through the full persisted path.
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;
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) : {})
});
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
+3 -121
View File
@@ -1,43 +1,10 @@
import { NitroLogger } from '@nitrots/nitro-renderer';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Dispatch, SetStateAction, useState } from 'react';
import { GetLocalStorage, SetLocalStorage } from '../api';
const userId = new URLSearchParams(window.location.search).get('userid') || 0;
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;
// Firefox legacy:
if(name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
return false;
};
const trimArrayForQuota = <T>(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<T>
{
/**
* Optional projection applied right before the value is written to
* localStorage. The in-memory React state is unaffected. Use this to
* strip heavy ephemeral fields (e.g. base64 image URLs) that would
* otherwise blow past the storage quota.
*/
toStorage?: (value: T) => unknown;
}
const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocalStorageOptions<T> = {}): [ T, Dispatch<SetStateAction<T>>] =>
const useLocalStorageState = <T>(key: string, initialValue: T): [ T, Dispatch<SetStateAction<T>>] =>
{
key = userId ? `${ key }.${ userId }` : key;
@@ -55,91 +22,6 @@ const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocal
}
});
const pendingWriteRef = useRef<T | null>(null);
const writeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const optionsRef = useRef(options);
// Keep the latest toStorage projection without re-running effects.
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;
}
}
// Quota exceeded - trim and retry once. Anything that isn't an
// array gets cleared, since we have no generic trimming rule.
try
{
const trimmed = trimArrayForQuota(projected as T);
SetLocalStorage(key, trimmed);
NitroLogger.warn(`[useLocalStorage] quota exceeded for ${ key }, trimmed payload`);
}
catch(retryError)
{
NitroLogger.error(retryError);
// Last resort: drop the key entirely so future writes have room.
try { window.localStorage.removeItem(key); } catch(_) { /* ignore */ }
}
};
// Debounce: high-frequency chat would otherwise trigger one full
// JSON.stringify + setItem per message. We coalesce bursts into one
// write per STORAGE_WRITE_DEBOUNCE_MS window with the latest value.
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);
};
// Flush a pending write on tab close / hide so we don't lose the last
// burst of activity.
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
@@ -148,7 +30,7 @@ const useLocalStorageState = <T>(key: string, initialValue: T, options: UseLocal
setStoredValue(valueToStore);
scheduleWrite(valueToStore);
if(typeof window !== 'undefined') SetLocalStorage(key, valueToStore);
}
catch(error)