diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 31a96439df..7c6577c01a 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -43,6 +43,7 @@ import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay import { emptyChannelWindowStore, flattenChannelWindowEvents, + mapChannelWindowEvents, mergeLiveChannelWindowEvent, mergeLiveThreadSummary, replaceNewestChannelWindow, @@ -769,23 +770,33 @@ export function useEditMessageMutation(channel: Channel | null) { return; } + // Apply-on-success cache update: reflect the edit's new content and + // imeta tag set immediately, so the local cache matches what the + // receiver overlay (formatTimelineMessages) will produce when the + // edit event arrives back from the relay. (Not a true optimistic + // update — runs in onSuccess, not onMutate. Worth bearing the cost + // only because the edit event round-trip can lag perceptibly.) + const applyEdit = (message: RelayEvent): RelayEvent => { + if (message.id !== eventId) return message; + const nextTags = mediaTags + ? applyEditTagOverlay(message.tags, mediaTags) + : message.tags; + return { ...message, content, tags: nextTags }; + }; + + // The WINDOW STORE is the source of truth: every live merge + // re-flattens it over `channelMessagesKey`, so patching only the + // flattened array gets reverted by the next live event (see + // mapChannelWindowEvents). Update the store first, then keep the + // flattened cache in step for immediate paint. + queryClient.setQueryData( + channelWindowKey(channel.id), + (current) => + current ? mapChannelWindowEvents(current, applyEdit) : current, + ); queryClient.setQueryData( channelMessagesKey(channel.id), - (current = []) => - current.map((message) => { - if (message.id !== eventId) return message; - // Apply-on-success cache update: reflect the edit's new content - // and imeta tag set immediately, so the local cache matches - // what the receiver overlay (formatTimelineMessages) will - // produce when the edit event arrives back from the relay. - // (Not a true optimistic update — runs in onSuccess, not - // onMutate. Worth bearing the cost only because the edit event - // round-trip can lag perceptibly.) - const nextTags = mediaTags - ? applyEditTagOverlay(message.tags, mediaTags) - : message.tags; - return { ...message, content, tags: nextTags }; - }), + (current = []) => current.map(applyEdit), ); }, }); diff --git a/desktop/src/features/messages/lib/channelWindowStore.test.mjs b/desktop/src/features/messages/lib/channelWindowStore.test.mjs index 91749ecf49..197941a65d 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.test.mjs +++ b/desktop/src/features/messages/lib/channelWindowStore.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { appendOlderChannelWindow, + mapChannelWindowEvents, channelWindowThreadSummaries, emptyChannelWindowStore, flattenChannelWindowEvents, @@ -342,3 +343,61 @@ test("live summaries survive scrollback pages and still win for their root", () store = appendOlderChannelWindow(store, tail); assert.equal(channelWindowThreadSummaries(store).get(older.id).replyCount, 5); }); + +test("mapChannelWindowEvents rewrites the event everywhere it lives", () => { + const target = event("target", 100); + const bystander = event("bystander", 90); + const auxEvent = event("aux-target", 95, 40003); + let store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [target, bystander], { aux: [auxEvent], hasMore: false }), + ); + store = mergeLiveChannelWindowEvent(store, event("live-target", 110)); + + const mapped = mapChannelWindowEvents(store, (candidate) => + candidate.id === target.id + ? { ...candidate, content: "edited" } + : candidate, + ); + assert.notEqual(mapped, store); + const flattened = flattenChannelWindowEvents(mapped); + assert.equal( + flattened.find((candidate) => candidate.id === target.id).content, + "edited", + ); + // Untouched events keep identity (no churn for memo consumers). + assert.equal( + flattened.find((candidate) => candidate.id === bystander.id), + bystander, + ); +}); + +test("mapChannelWindowEvents returns the same store when nothing matches", () => { + const store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("a", 100)], { hasMore: false }), + ); + assert.equal( + mapChannelWindowEvents(store, (candidate) => candidate), + store, + ); +}); + +test("mapChannelWindowEvents rewrites live overlay events", () => { + const liveEvent = event("live", 110); + const store = mergeLiveChannelWindowEvent( + emptyChannelWindowStore(), + liveEvent, + ); + const mapped = mapChannelWindowEvents(store, (candidate) => + candidate.id === liveEvent.id + ? { ...candidate, content: "edited-live" } + : candidate, + ); + assert.equal( + flattenChannelWindowEvents(mapped).find( + (candidate) => candidate.id === liveEvent.id, + ).content, + "edited-live", + ); +}); diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index c41d78501a..7ee883bf23 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -215,6 +215,47 @@ export function mergeLiveChannelWindowEvent( }; } +/** + * Apply a per-event transform across every event the store holds (page rows, + * page aux, live overlay, live aux), returning the same store reference when + * nothing changed. + * + * Local writes MUST go through this rather than patching the flattened + * `channelMessagesKey` array alone: the window store is the source of truth, + * and every live merge re-flattens it into `channelMessagesKey` + * (`flattenChannelWindowEvents`), so an update applied only to the flattened + * array is silently reverted by the next live event. The message-edit + * apply-on-success update hit exactly that: the edit rendered, then the next + * live merge re-flattened the un-edited store over it. (Masked on busy + * screens because unrelated re-renders kept re-deriving state; exposed once + * per-keystroke app-shell renders were removed.) + */ +export function mapChannelWindowEvents( + store: ChannelWindowStore, + map: (event: RelayEvent) => RelayEvent, +): ChannelWindowStore { + let changed = false; + const mapEvent = (event: RelayEvent) => { + const next = map(event); + if (next !== event) changed = true; + return next; + }; + const pages = store.pages.map((page) => { + const rows = page.rows.map((row) => { + const event = mapEvent(row.event); + return event === row.event ? row : { ...row, event }; + }); + const aux = page.aux.map(mapEvent); + return rows.every((row, index) => row === page.rows[index]) && + aux.every((event, index) => event === page.aux[index]) + ? page + : { ...page, rows, aux }; + }); + const liveOverlay = store.liveOverlay.map(mapEvent); + const liveAux = store.liveAux.map(mapEvent); + return changed ? { ...store, pages, liveOverlay, liveAux } : store; +} + /** Raw events in the chronological order expected by the existing renderer. */ export function flattenChannelWindowEvents(store: ChannelWindowStore) { const byId = new Map(); diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts index ed205d82e4..dad4d8c264 100644 --- a/desktop/src/features/presence/hooks.ts +++ b/desktop/src/features/presence/hooks.ts @@ -197,21 +197,42 @@ export function usePresenceSession(pubkey?: string) { React.useState(() => readStoredPresencePreference(normalizedPubkey), ); - const [lastActivityAt, setLastActivityAt] = React.useState(() => Date.now()); - const [statusClock, setStatusClock] = React.useState(() => Date.now()); - const [isDocumentHidden, setIsDocumentHidden] = React.useState(() => - typeof document === "undefined" ? false : document.hidden, + // Activity is tracked in a REF, and React state holds only the DERIVED + // status. The previous shape stored raw `Date.now()` timestamps in state + // and bumped them from a capture-phase `keydown`/`pointerdown` listener — + // which re-rendered this hook's host (AppShell, the app root) on every + // keystroke the user typed anywhere in the app. Presence only needs a + // render when the automatic status actually flips (online <-> away), so + // activity updates the ref and re-derives; setState fires on transitions + // only (see typing-latency.perf.ts / keystroke input-to-paint). + const lastActivityAtRef = React.useRef(Date.now()); + const [automaticStatus, setAutomaticStatus] = React.useState( + () => + resolveAutomaticPresenceStatus( + typeof document === "undefined" ? false : document.hidden, + lastActivityAtRef.current, + Date.now(), + ), ); + const automaticStatusRef = React.useRef(automaticStatus); const skipNextSyncRef = React.useRef(null); - React.useEffect(() => { - const now = Date.now(); - setPresencePreference(readStoredPresencePreference(normalizedPubkey)); - setLastActivityAt(now); - setStatusClock(now); - setIsDocumentHidden( + const reevaluateAutomaticStatus = React.useEffectEvent(() => { + const next = resolveAutomaticPresenceStatus( typeof document === "undefined" ? false : document.hidden, + lastActivityAtRef.current, + Date.now(), ); + if (next !== automaticStatusRef.current) { + automaticStatusRef.current = next; + setAutomaticStatus(next); + } + }); + + React.useEffect(() => { + setPresencePreference(readStoredPresencePreference(normalizedPubkey)); + lastActivityAtRef.current = Date.now(); + reevaluateAutomaticStatus(); }, [normalizedPubkey]); React.useEffect(() => { @@ -219,9 +240,8 @@ export function usePresenceSession(pubkey?: string) { }, [normalizedPubkey, presencePreference]); const recordActivity = React.useEffectEvent(() => { - const now = Date.now(); - setLastActivityAt(now); - setStatusClock(now); + lastActivityAtRef.current = Date.now(); + reevaluateAutomaticStatus(); }); React.useEffect(() => { @@ -238,17 +258,16 @@ export function usePresenceSession(pubkey?: string) { } function handleFocus() { - setIsDocumentHidden(false); recordActivity(); } function handleVisibilityChange() { - const hidden = document.hidden; - setIsDocumentHidden(hidden); - - if (!hidden) { + if (!document.hidden) { recordActivity(); + return; } + // Going hidden can flip the automatic status to away. + reevaluateAutomaticStatus(); } window.addEventListener("pointerdown", handleUserActivity, true); @@ -270,23 +289,13 @@ export function usePresenceSession(pubkey?: string) { } const intervalId = window.setInterval(() => { - setStatusClock(Date.now()); + reevaluateAutomaticStatus(); }, PRESENCE_STATUS_TICK_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; }, [normalizedPubkey]); - - const automaticStatus = React.useMemo( - () => - resolveAutomaticPresenceStatus( - isDocumentHidden, - lastActivityAt, - statusClock, - ), - [isDocumentHidden, lastActivityAt, statusClock], - ); const currentStatus = normalizedPubkey.length === 0 ? "offline" @@ -305,12 +314,8 @@ export function usePresenceSession(pubkey?: string) { status === "online" ? "auto" : status; if (nextPreference === "auto") { - const now = Date.now(); - setLastActivityAt(now); - setStatusClock(now); - setIsDocumentHidden( - typeof document === "undefined" ? false : document.hidden, - ); + lastActivityAtRef.current = Date.now(); + reevaluateAutomaticStatus(); } setPresencePreference(nextPreference); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 1c7ad232c6..a6b446c8e6 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -490,7 +490,19 @@ export function useUpdateProfileMutation() { return useMutation({ mutationFn: (input: UpdateProfileInput) => updateProfile(input), - onSuccess: (profile: Profile) => { + onMutate: async () => { + // Discard any in-flight profile fetch: a background refetch started + // before the update (e.g. by a route mount) can resolve AFTER + // onSuccess writes the fresh profile below and clobber it with the + // pre-update snapshot — the avatar/name then silently reverts until + // some later refetch. (Masked historically by users-batch refetch + // churn from per-keystroke app renders; exposed when those renders + // were removed.) + await queryClient.cancelQueries({ queryKey: profileQueryKey }); + }, + onSuccess: async (profile: Profile) => { + // Cancel again: a refetch may have started while mutationFn awaited. + await queryClient.cancelQueries({ queryKey: profileQueryKey }); queryClient.setQueryData(profileQueryKey, profile); const relayUrl = activeWorkspace?.relayUrl ?? ""; const pubkey = identityQuery.data?.pubkey ?? profile.pubkey;