fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO): - F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED logout too (token expiry / remote sign-out) — only manual logout did before. F4: the delete no longer reports success while onblocked (waits, 3s cap). - M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now serialized at MODULE scope (single queue + latestRef per client, echo-driven), fixing the cross-instance lost-update clobber (useBookmarks mounts per message row, so a per-instance queue was insufficient — caught in review). - M6: room-history export gets a 200-page cap + Cancel + unmount-abort + correct date-range early-break (raw paginated ts). M4: image compression skips PNG (was flattening transparency to black), bakes EXIF orientation via createImageBitmap, .jpg-renames, and falls back to the original on decode failure instead of dropping the file. M5: MediaGallery lightbox opens the right item (shared thumb guard). M8: audio speed survives async decrypt. - Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5 updater has a terminal state. Reviewed; M7 reverted (past-time clamp is an intentional, tested contract). tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
|
||||
export type Bookmark = {
|
||||
roomId: string;
|
||||
@@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] {
|
||||
);
|
||||
}
|
||||
|
||||
// Module-scoped serialization state.
|
||||
//
|
||||
// useBookmarks() is mounted once per message row (dozens of live instances), so
|
||||
// a per-instance latest/queue would only serialize writes within a single row —
|
||||
// bookmarking message A then message B from different rows (before the server
|
||||
// echo lands) would let B compute from a stale snapshot and clobber A
|
||||
// (setAccountData replaces the whole content, no server merge). We therefore
|
||||
// keep a single shared latest ref + write queue, keyed off the active client.
|
||||
type BookmarksModuleState = {
|
||||
mx: MatrixClient;
|
||||
latest: Bookmark[];
|
||||
writeQueue: Promise<unknown>;
|
||||
listeners: Set<(list: Bookmark[]) => void>;
|
||||
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||
};
|
||||
|
||||
let moduleState: BookmarksModuleState | null = null;
|
||||
|
||||
// Lazily initialize the shared state for the given client. On a client change
|
||||
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||
// re-initialize against the new client so we never leak or double-subscribe.
|
||||
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
|
||||
if (moduleState && moduleState.mx === mx) {
|
||||
return moduleState;
|
||||
}
|
||||
|
||||
if (moduleState) {
|
||||
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||
}
|
||||
|
||||
const state: BookmarksModuleState = {
|
||||
mx,
|
||||
latest: readBookmarks(mx),
|
||||
writeQueue: Promise.resolve(),
|
||||
listeners: new Set(),
|
||||
// Reassigned below once `state` is captured.
|
||||
onAccountData: () => undefined,
|
||||
};
|
||||
|
||||
state.onAccountData = (evt) => {
|
||||
if (evt.getType() === BOOKMARKS_KEY) {
|
||||
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
|
||||
state.latest = list;
|
||||
state.listeners.forEach((listener) => listener(list));
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||
moduleState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function enqueueBookmarkWrite(
|
||||
mx: MatrixClient,
|
||||
compute: (current: Bookmark[]) => Bookmark[],
|
||||
): Promise<void> {
|
||||
const state = ensureModuleState(mx);
|
||||
const run = state.writeQueue.then(async () => {
|
||||
const next = compute(state.latest);
|
||||
state.latest = next;
|
||||
state.listeners.forEach((listener) => listener(next));
|
||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||
});
|
||||
// Keep the chain alive even if one write rejects, but propagate the
|
||||
// rejection to this caller so it can react (e.g. retry).
|
||||
state.writeQueue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
export function useBookmarks(): {
|
||||
bookmarks: Bookmark[];
|
||||
addBookmark: (b: Bookmark) => Promise<void>;
|
||||
@@ -32,45 +100,37 @@ export function useBookmarks(): {
|
||||
isBookmarked: (eventId: string) => boolean;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (evt.getType() === BOOKMARKS_KEY) {
|
||||
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
|
||||
}
|
||||
},
|
||||
[setBookmarks],
|
||||
),
|
||||
);
|
||||
|
||||
// Re-read on mx change
|
||||
// Subscribe to the shared module state. A single AccountData listener is
|
||||
// installed per client (in ensureModuleState); each hook instance only
|
||||
// registers a local setter and unregisters it on unmount / client change.
|
||||
useEffect(() => {
|
||||
setBookmarks(readBookmarks(mx));
|
||||
const state = ensureModuleState(mx);
|
||||
setBookmarks(state.latest);
|
||||
state.listeners.add(setBookmarks);
|
||||
return () => {
|
||||
state.listeners.delete(setBookmarks);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const addBookmark = useCallback(
|
||||
async (b: Bookmark) => {
|
||||
const current = readBookmarks(mx);
|
||||
// Avoid duplicates
|
||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||
let next = [b, ...filtered];
|
||||
if (next.length > MAX_BOOKMARKS) {
|
||||
next = next.slice(0, MAX_BOOKMARKS);
|
||||
}
|
||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||
},
|
||||
(b: Bookmark) =>
|
||||
enqueueBookmarkWrite(mx, (current) => {
|
||||
// Avoid duplicates
|
||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||
let next = [b, ...filtered];
|
||||
if (next.length > MAX_BOOKMARKS) {
|
||||
next = next.slice(0, MAX_BOOKMARKS);
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
[mx],
|
||||
);
|
||||
|
||||
const removeBookmark = useCallback(
|
||||
async (eventId: string) => {
|
||||
const current = readBookmarks(mx);
|
||||
const next = current.filter((bk) => bk.eventId !== eventId);
|
||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
||||
},
|
||||
(eventId: string) =>
|
||||
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||
[mx],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
|
||||
export type Reminder = {
|
||||
roomId: string;
|
||||
@@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] {
|
||||
);
|
||||
}
|
||||
|
||||
// Module-scoped serialization state.
|
||||
//
|
||||
// The latest snapshot and the write queue must be shared across every hook
|
||||
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
|
||||
// (adds reminders) mount separate hooks, and a per-instance queue would let a
|
||||
// remove and an add race across instances and clobber each other (setAccountData
|
||||
// replaces the whole content, no server merge). We therefore keep a single
|
||||
// shared queue + latest ref, keyed off the active MatrixClient.
|
||||
type ReminderModuleState = {
|
||||
mx: MatrixClient;
|
||||
latest: Reminder[];
|
||||
writeQueue: Promise<unknown>;
|
||||
listeners: Set<(list: Reminder[]) => void>;
|
||||
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||
};
|
||||
|
||||
let moduleState: ReminderModuleState | null = null;
|
||||
|
||||
// Lazily initialize the shared state for the given client. On a client change
|
||||
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||
// re-initialize against the new client so we never leak or double-subscribe.
|
||||
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
|
||||
if (moduleState && moduleState.mx === mx) {
|
||||
return moduleState;
|
||||
}
|
||||
|
||||
if (moduleState) {
|
||||
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||
}
|
||||
|
||||
const state: ReminderModuleState = {
|
||||
mx,
|
||||
latest: readReminders(mx),
|
||||
writeQueue: Promise.resolve(),
|
||||
listeners: new Set(),
|
||||
// Reassigned below once `state` is captured.
|
||||
onAccountData: () => undefined,
|
||||
};
|
||||
|
||||
state.onAccountData = (evt) => {
|
||||
if (evt.getType() === REMINDERS_KEY) {
|
||||
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
|
||||
state.latest = list;
|
||||
state.listeners.forEach((listener) => listener(list));
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||
moduleState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function enqueueReminderWrite(
|
||||
mx: MatrixClient,
|
||||
compute: (current: Reminder[]) => Reminder[],
|
||||
): Promise<void> {
|
||||
const state = ensureModuleState(mx);
|
||||
const run = state.writeQueue.then(async () => {
|
||||
const next = compute(state.latest);
|
||||
state.latest = next;
|
||||
state.listeners.forEach((listener) => listener(next));
|
||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
||||
});
|
||||
// Keep the chain alive even if one write rejects, but propagate the
|
||||
// rejection to this caller so it can react (e.g. retry).
|
||||
state.writeQueue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
export function useReminders(): {
|
||||
reminders: Reminder[];
|
||||
addReminder: (r: Reminder) => Promise<void>;
|
||||
@@ -30,69 +98,34 @@ export function useReminders(): {
|
||||
getReminders: () => Reminder[];
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
||||
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
|
||||
|
||||
// Authoritative local snapshot used to compute mutations. Reading
|
||||
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
|
||||
// read the same stale baseline and the second write clobbers the first
|
||||
// (N113). We instead mutate from this ref, kept in sync with server echoes.
|
||||
const latestRef = useRef<Reminder[]>(reminders);
|
||||
// Serialize writes so overlapping setAccountData calls can't land out of
|
||||
// order on the server (last-write-wins would otherwise drop data).
|
||||
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
const applyServerState = useCallback((list: Reminder[]) => {
|
||||
latestRef.current = list;
|
||||
setReminders(list);
|
||||
}, []);
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (evt.getType() === REMINDERS_KEY) {
|
||||
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
|
||||
}
|
||||
},
|
||||
[applyServerState],
|
||||
),
|
||||
);
|
||||
|
||||
// Re-read on mx change
|
||||
// Subscribe to the shared module state. A single AccountData listener is
|
||||
// installed per client (in ensureModuleState); each hook instance only
|
||||
// registers a local setter and unregisters it on unmount / client change.
|
||||
useEffect(() => {
|
||||
applyServerState(readReminders(mx));
|
||||
}, [mx, applyServerState]);
|
||||
|
||||
const enqueueWrite = useCallback(
|
||||
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
|
||||
const run = writeQueueRef.current.then(async () => {
|
||||
const next = compute(latestRef.current);
|
||||
latestRef.current = next;
|
||||
setReminders(next);
|
||||
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
|
||||
});
|
||||
// Keep the chain alive even if one write rejects, but propagate the
|
||||
// rejection to this caller so it can react (e.g. retry).
|
||||
writeQueueRef.current = run.catch(() => undefined);
|
||||
return run;
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
const state = ensureModuleState(mx);
|
||||
setReminders(state.latest);
|
||||
state.listeners.add(setReminders);
|
||||
return () => {
|
||||
state.listeners.delete(setReminders);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const addReminder = useCallback(
|
||||
(r: Reminder) => enqueueWrite((current) => [...current, r]),
|
||||
[enqueueWrite],
|
||||
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
|
||||
[mx],
|
||||
);
|
||||
|
||||
const removeReminder = useCallback(
|
||||
(eventId: string, timestamp: number) =>
|
||||
enqueueWrite((current) =>
|
||||
enqueueReminderWrite(mx, (current) =>
|
||||
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
||||
),
|
||||
[enqueueWrite],
|
||||
[mx],
|
||||
);
|
||||
|
||||
const getReminders = useCallback(() => reminders, [reminders]);
|
||||
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
|
||||
|
||||
return { reminders, addReminder, removeReminder, getReminders };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { manualDndAtom } from '../state/manualDnd';
|
||||
import { useTauriEvent } from './useTauri';
|
||||
import { tauriInvoke, useTauriEvent } from './useTauri';
|
||||
|
||||
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
||||
type DndChangedDetail = {
|
||||
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
|
||||
const setDnd = useSetAtom(manualDndAtom);
|
||||
|
||||
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
||||
|
||||
// Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but
|
||||
// `manualDndAtom` is in-memory and resets to false on every reload (the
|
||||
// custom-chrome toggle, logout). Without this the tray could show DND ON while
|
||||
// notifications resume firing. Query the native tray state (`get_tray_dnd`) so
|
||||
// they stay in sync. No-op in the browser.
|
||||
useEffect(() => {
|
||||
tauriInvoke()?.('get_tray_dnd')
|
||||
.then((active) => {
|
||||
if (typeof active === 'boolean') setDnd(active);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [setDnd]);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
|
||||
|
||||
let totalHighlights = 0;
|
||||
roomToUnread.forEach((unread) => {
|
||||
// Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor
|
||||
// space aggregates (from = Set), so counting all entries double-counts a
|
||||
// space-nested room. Mirrors the favicon fix in ClientNonUIFeatures.
|
||||
if (unread.from !== null) return;
|
||||
totalHighlights += unread.highlight;
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
|
||||
setStatus({ state: 'installing' });
|
||||
try {
|
||||
await invoke('install_update');
|
||||
// On a successful install the native side calls app.restart(), so this
|
||||
// resolve is only reached when nothing was installed (no update found) —
|
||||
// don't leave the UI stuck on "installing".
|
||||
setStatus({ state: 'up-to-date' });
|
||||
} catch (e) {
|
||||
setStatus({ state: 'error', message: String(e) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||
|
||||
const NOTES_KEY = 'io.lotus.user_notes';
|
||||
export const USER_NOTE_MAX_LENGTH = 500;
|
||||
@@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent {
|
||||
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
|
||||
}
|
||||
|
||||
// Module-scoped serialization state.
|
||||
//
|
||||
// useUserNotes() can be mounted by many components at once, so a per-instance
|
||||
// latest/queue would only serialize writes within one instance. Notes for
|
||||
// different users saved from different instances (before the server echo lands)
|
||||
// would each compute from a stale snapshot and clobber each other, since
|
||||
// setAccountData replaces the whole record with no server merge. We therefore
|
||||
// keep a single shared latest record + write queue, keyed off the active client.
|
||||
type UserNotesModuleState = {
|
||||
mx: MatrixClient;
|
||||
latest: UserNotesContent;
|
||||
writeQueue: Promise<unknown>;
|
||||
listeners: Set<(record: UserNotesContent) => void>;
|
||||
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
|
||||
};
|
||||
|
||||
let moduleState: UserNotesModuleState | null = null;
|
||||
|
||||
// Lazily initialize the shared state for the given client. On a client change
|
||||
// (login/logout swaps the MatrixClient) we tear down the old subscription and
|
||||
// re-initialize against the new client so we never leak or double-subscribe.
|
||||
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
|
||||
if (moduleState && moduleState.mx === mx) {
|
||||
return moduleState;
|
||||
}
|
||||
|
||||
if (moduleState) {
|
||||
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
|
||||
}
|
||||
|
||||
const state: UserNotesModuleState = {
|
||||
mx,
|
||||
latest: readNotes(mx),
|
||||
writeQueue: Promise.resolve(),
|
||||
listeners: new Set(),
|
||||
// Reassigned below once `state` is captured.
|
||||
onAccountData: () => undefined,
|
||||
};
|
||||
|
||||
state.onAccountData = (evt) => {
|
||||
if (evt.getType() === NOTES_KEY) {
|
||||
const record = evt.getContent<UserNotesContent>() ?? {};
|
||||
state.latest = record;
|
||||
state.listeners.forEach((listener) => listener(record));
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, state.onAccountData);
|
||||
moduleState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function enqueueNotesWrite(
|
||||
mx: MatrixClient,
|
||||
compute: (current: UserNotesContent) => UserNotesContent,
|
||||
): Promise<void> {
|
||||
const state = ensureModuleState(mx);
|
||||
const run = state.writeQueue.then(async () => {
|
||||
const next = compute(state.latest);
|
||||
state.latest = next;
|
||||
state.listeners.forEach((listener) => listener(next));
|
||||
await (mx as any).setAccountData(NOTES_KEY, next);
|
||||
});
|
||||
// Keep the chain alive even if one write rejects, but propagate the
|
||||
// rejection to this caller so it can react (e.g. retry).
|
||||
state.writeQueue = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
export function useUserNotes(): {
|
||||
getNote: (userId: string) => string;
|
||||
setNote: (userId: string, note: string) => Promise<void>;
|
||||
} {
|
||||
const mx = useMatrixClient();
|
||||
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback((evt) => {
|
||||
if (evt.getType() === NOTES_KEY) {
|
||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
||||
}
|
||||
}, []),
|
||||
);
|
||||
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
|
||||
|
||||
// Subscribe to the shared module state. A single AccountData listener is
|
||||
// installed per client (in ensureModuleState); each hook instance only
|
||||
// registers a local setter and unregisters it on unmount / client change.
|
||||
useEffect(() => {
|
||||
setNotes(readNotes(mx));
|
||||
const state = ensureModuleState(mx);
|
||||
setNotes(state.latest);
|
||||
state.listeners.add(setNotes);
|
||||
return () => {
|
||||
state.listeners.delete(setNotes);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
||||
|
||||
const setNote = useCallback(
|
||||
async (userId: string, note: string) => {
|
||||
const current = readNotes(mx);
|
||||
const updated = { ...current };
|
||||
(userId: string, note: string) => {
|
||||
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
||||
if (trimmed) {
|
||||
updated[userId] = trimmed;
|
||||
} else {
|
||||
delete updated[userId];
|
||||
}
|
||||
await (mx as any).setAccountData(NOTES_KEY, updated);
|
||||
return enqueueNotesWrite(mx, (current) => {
|
||||
const updated = { ...current };
|
||||
if (trimmed) {
|
||||
updated[userId] = trimmed;
|
||||
} else {
|
||||
delete updated[userId];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user