Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 26 additions & 15 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay
import {
emptyChannelWindowStore,
flattenChannelWindowEvents,
mapChannelWindowEvents,
mergeLiveChannelWindowEvent,
mergeLiveThreadSummary,
replaceNewestChannelWindow,
Expand Down Expand Up @@ -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<ChannelWindowStore>(
channelWindowKey(channel.id),
(current) =>
current ? mapChannelWindowEvents(current, applyEdit) : current,
);
queryClient.setQueryData<RelayEvent[]>(
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),
);
},
});
Expand Down
59 changes: 59 additions & 0 deletions desktop/src/features/messages/lib/channelWindowStore.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
appendOlderChannelWindow,
mapChannelWindowEvents,
channelWindowThreadSummaries,
emptyChannelWindowStore,
flattenChannelWindowEvents,
Expand Down Expand Up @@ -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",
);
});
41 changes: 41 additions & 0 deletions desktop/src/features/messages/lib/channelWindowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RelayEvent>();
Expand Down
75 changes: 40 additions & 35 deletions desktop/src/features/presence/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,31 +197,51 @@ export function usePresenceSession(pubkey?: string) {
React.useState<PresencePreference>(() =>
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<PresenceStatus>(
() =>
resolveAutomaticPresenceStatus(
typeof document === "undefined" ? false : document.hidden,
lastActivityAtRef.current,
Date.now(),
),
);
const automaticStatusRef = React.useRef(automaticStatus);
const skipNextSyncRef = React.useRef<PresenceStatus | null>(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(() => {
writeStoredPresencePreference(normalizedPubkey, presencePreference);
}, [normalizedPubkey, presencePreference]);

const recordActivity = React.useEffectEvent(() => {
const now = Date.now();
setLastActivityAt(now);
setStatusClock(now);
lastActivityAtRef.current = Date.now();
reevaluateAutomaticStatus();
});

React.useEffect(() => {
Expand All @@ -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);
Expand All @@ -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"
Expand All @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion desktop/src/features/profile/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading