mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge remote-tracking branch 'origin/main' into perf/last-message-distinct-on
* origin/main: fix(desktop): simplify workspace rail badges (#1462) perf(desktop): instant channel switching — non-blocking first paint, persisted snapshots (#1452) Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
commit
9ce97bdca7
@@ -33,10 +33,16 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
|
||||
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
|
||||
import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
|
||||
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import {
|
||||
mergeHistoryOverSnapshot,
|
||||
readMessageSnapshot,
|
||||
writeMessageSnapshot,
|
||||
} from "@/features/messages/lib/messageSnapshot";
|
||||
import {
|
||||
MIN_TOP_LEVEL_ROWS_PER_FETCH,
|
||||
pageOlderMessagesUntilRowFloor,
|
||||
} from "@/features/messages/lib/pageOlderMessages";
|
||||
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
@@ -171,10 +177,24 @@ export function createOptimisticMessage(
|
||||
export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = channelMessagesKey(channel?.id ?? "none");
|
||||
const { activeWorkspace } = useWorkspaces();
|
||||
const relayUrl = activeWorkspace?.relayUrl ?? null;
|
||||
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
enabled: channel !== null && channel.channelType !== "forum",
|
||||
placeholderData: () => queryClient.getQueryData<RelayEvent[]>(queryKey),
|
||||
// Paint instantly from the in-memory cache, or — after a restart / gc —
|
||||
// from the persisted per-channel snapshot, then revalidate behind it.
|
||||
placeholderData: () => {
|
||||
const cached = queryClient.getQueryData<RelayEvent[]>(queryKey);
|
||||
if (cached && cached.length > 0) {
|
||||
return cached;
|
||||
}
|
||||
if (!channel || !relayUrl) {
|
||||
return undefined;
|
||||
}
|
||||
const snapshot = readMessageSnapshot(relayUrl, channel.id);
|
||||
return snapshot ? normalizeTimelineMessages(snapshot) : undefined;
|
||||
},
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!channel) {
|
||||
@@ -185,36 +205,64 @@ export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
channel.id,
|
||||
CHANNEL_HISTORY_LIMIT,
|
||||
);
|
||||
const currentMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const mergedHistory = mergeTimelineHistoryMessages(
|
||||
currentMessages,
|
||||
history,
|
||||
);
|
||||
// Merge over the cache, or over the persisted snapshot when cold; a
|
||||
// cold snapshot load widens the aux backfill to the merged timeline so
|
||||
// tombstones/edits for snapshot-only rows are fetched (see helper doc).
|
||||
const cached = queryClient.getQueryData<RelayEvent[]>(queryKey);
|
||||
const { merged: mergedHistory, auxBackfillWindow } =
|
||||
mergeHistoryOverSnapshot({
|
||||
cached,
|
||||
snapshot:
|
||||
!cached && relayUrl
|
||||
? readMessageSnapshot(relayUrl, channel.id)
|
||||
: null,
|
||||
history,
|
||||
});
|
||||
|
||||
// Paint messages immediately; backfill their reactions/edits/deletions
|
||||
// by `#e` in the background (it self-merges into the same cache key).
|
||||
void backfillAuxForMessages(queryClient, channel.id, history);
|
||||
void backfillAuxForMessages(queryClient, channel.id, auxBackfillWindow);
|
||||
|
||||
// Seed the cache, then — only if the cold window renders thinner than a
|
||||
// normal scroll page — top it up to the same visible-row floor. A
|
||||
// reply-heavy channel's 300-message cold load can be ~12 rows; a normal
|
||||
// channel already clears the floor and skips the extra fetch entirely.
|
||||
// Seed the cache and paint immediately; if the cold window renders
|
||||
// thinner than a normal scroll page (reply-heavy channels), top it up
|
||||
// in the background — it self-merges into the same cache key.
|
||||
queryClient.setQueryData<RelayEvent[]>(queryKey, mergedHistory);
|
||||
if (
|
||||
countTopLevelTimelineRows(mergedHistory) < MIN_TOP_LEVEL_ROWS_PER_FETCH
|
||||
) {
|
||||
await pageOlderMessagesUntilRowFloor(
|
||||
void pageOlderMessagesUntilRowFloor(
|
||||
queryClient,
|
||||
channel.id,
|
||||
() => true,
|
||||
);
|
||||
).catch((error) => {
|
||||
console.error("Failed to top up channel history", channel.id, error);
|
||||
});
|
||||
}
|
||||
return queryClient.getQueryData<RelayEvent[]>(queryKey) ?? mergedHistory;
|
||||
},
|
||||
staleTime: 5 * 60 * 1_000,
|
||||
gcTime: 5 * 60 * 1_000,
|
||||
// Long in-memory retention: a channel revisited within the hour paints
|
||||
// from cache with zero relay round trips; the persisted snapshot covers
|
||||
// restarts beyond it.
|
||||
gcTime: 60 * 60 * 1_000,
|
||||
});
|
||||
|
||||
// Persist the newest slice after each settled update so the next cold open
|
||||
// (restart, gc) paints from the snapshot. Placeholder frames are skipped —
|
||||
// they are what the snapshot painted, not new information.
|
||||
const persistSnapshot = useEffectEvent((events: RelayEvent[]) => {
|
||||
if (relayUrl && channel) {
|
||||
writeMessageSnapshot(relayUrl, channel.id, events);
|
||||
}
|
||||
});
|
||||
const settledData = query.isPlaceholderData ? undefined : query.data;
|
||||
useEffect(() => {
|
||||
if (settledData && settledData.length > 0) {
|
||||
persistSnapshot(settledData);
|
||||
}
|
||||
}, [settledData]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useChannelSubscription(channel: Channel | null) {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
mergeHistoryOverSnapshot,
|
||||
messageSnapshotKey,
|
||||
readMessageSnapshot,
|
||||
removeMessageSnapshotsForRelay,
|
||||
writeMessageSnapshot,
|
||||
} from "./messageSnapshot.ts";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
key: (index) => [...storage.keys()][index] ?? null,
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(overrides = {}) {
|
||||
return {
|
||||
id: `event-${Math.random().toString(36).slice(2)}`,
|
||||
pubkey: "pubkey-1",
|
||||
created_at: 1_700_000_000,
|
||||
kind: 9,
|
||||
tags: [["h", "chan-1"]],
|
||||
content: "hello",
|
||||
sig: "sig",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
|
||||
function clearRelay(relayUrl = RELAY) {
|
||||
removeMessageSnapshotsForRelay(relayUrl);
|
||||
}
|
||||
|
||||
test("messageSnapshotKey: normalizes trailing slash and case", () => {
|
||||
assert.equal(
|
||||
messageSnapshotKey("WSS://Relay.Example.com/", "chan-1"),
|
||||
messageSnapshotKey("wss://relay.example.com", "chan-1"),
|
||||
);
|
||||
});
|
||||
|
||||
test("read after write returns the persisted events", () => {
|
||||
clearRelay();
|
||||
const events = [makeEvent({ id: "a" }), makeEvent({ id: "b" })];
|
||||
writeMessageSnapshot(RELAY, "chan-1", events);
|
||||
assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), events);
|
||||
});
|
||||
|
||||
test("read for an unknown channel returns null", () => {
|
||||
clearRelay();
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-never"), null);
|
||||
});
|
||||
|
||||
test("read returns null for malformed JSON", () => {
|
||||
window.localStorage.setItem(
|
||||
messageSnapshotKey(RELAY, "chan-bad"),
|
||||
"not-json{{{",
|
||||
);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-bad"), null);
|
||||
});
|
||||
|
||||
test("read returns null for a wrong-version payload", () => {
|
||||
window.localStorage.setItem(
|
||||
messageSnapshotKey(RELAY, "chan-v2"),
|
||||
JSON.stringify({ version: 2, updatedAt: 1, events: [makeEvent()] }),
|
||||
);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-v2"), null);
|
||||
});
|
||||
|
||||
test("pending optimistic events are not persisted", () => {
|
||||
clearRelay();
|
||||
const settled = makeEvent({ id: "settled" });
|
||||
writeMessageSnapshot(RELAY, "chan-1", [
|
||||
settled,
|
||||
makeEvent({ id: "optimistic", pending: true }),
|
||||
]);
|
||||
assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), [settled]);
|
||||
});
|
||||
|
||||
test("write with only pending events persists nothing", () => {
|
||||
clearRelay();
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ pending: true })]);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-1"), null);
|
||||
});
|
||||
|
||||
test("snapshot keeps only the newest slice of a long timeline", () => {
|
||||
clearRelay();
|
||||
const events = Array.from({ length: 200 }, (_, i) =>
|
||||
makeEvent({ id: `event-${i}`, created_at: 1_700_000_000 + i }),
|
||||
);
|
||||
writeMessageSnapshot(RELAY, "chan-1", events);
|
||||
const persisted = readMessageSnapshot(RELAY, "chan-1");
|
||||
assert.equal(persisted.length, 80);
|
||||
assert.equal(persisted[persisted.length - 1].id, "event-199");
|
||||
assert.equal(persisted[0].id, "event-120");
|
||||
});
|
||||
|
||||
test("per-relay channel cap evicts the least recently written snapshot", () => {
|
||||
clearRelay();
|
||||
for (let i = 0; i < 21; i++) {
|
||||
writeMessageSnapshot(RELAY, `chan-${i}`, [makeEvent({ id: `e-${i}` })]);
|
||||
}
|
||||
// chan-0 was written first (oldest updatedAt tie broken by insertion) —
|
||||
// with 21 channels, at least one of the earliest must be evicted and the
|
||||
// newest retained.
|
||||
assert.notEqual(readMessageSnapshot(RELAY, "chan-20"), null);
|
||||
const retained = Array.from({ length: 21 }, (_, i) =>
|
||||
readMessageSnapshot(RELAY, `chan-${i}`),
|
||||
).filter((snapshot) => snapshot !== null);
|
||||
assert.equal(retained.length, 20);
|
||||
});
|
||||
|
||||
test("remove clears every snapshot for that relay only", () => {
|
||||
clearRelay();
|
||||
clearRelay("wss://other.example.com");
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ id: "keep-other" })]);
|
||||
writeMessageSnapshot("wss://other.example.com", "chan-1", [
|
||||
makeEvent({ id: "other" }),
|
||||
]);
|
||||
removeMessageSnapshotsForRelay(RELAY);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-1"), null);
|
||||
assert.notEqual(
|
||||
readMessageSnapshot("wss://other.example.com", "chan-1"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("write is tolerant of storage failures", () => {
|
||||
const original = window.localStorage.setItem;
|
||||
window.localStorage.setItem = () => {
|
||||
throw new Error("quota exceeded");
|
||||
};
|
||||
try {
|
||||
assert.doesNotThrow(() =>
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent()]),
|
||||
);
|
||||
} finally {
|
||||
window.localStorage.setItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("cold snapshot load: merge keeps snapshot-only rows and widens aux backfill to them", () => {
|
||||
const snapshotOnly = makeEvent({ id: "ghost", created_at: 1_700_000_000 });
|
||||
const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: undefined,
|
||||
snapshot: [snapshotOnly],
|
||||
history: [fresh],
|
||||
});
|
||||
assert.deepEqual(
|
||||
merged.map((event) => event.id),
|
||||
["ghost", "fresh"],
|
||||
);
|
||||
assert.ok(auxBackfillWindow.some((event) => event.id === "ghost"));
|
||||
assert.ok(auxBackfillWindow.some((event) => event.id === "fresh"));
|
||||
});
|
||||
|
||||
test("warm load: aux backfill stays scoped to the fresh window", () => {
|
||||
const cached = makeEvent({ id: "cached", created_at: 1_700_000_000 });
|
||||
const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: [cached],
|
||||
snapshot: [makeEvent({ id: "stale-snapshot" })],
|
||||
history: [fresh],
|
||||
});
|
||||
assert.ok(merged.some((event) => event.id === "cached"));
|
||||
assert.deepEqual(
|
||||
auxBackfillWindow.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
});
|
||||
|
||||
test("cold load without a snapshot backfills the fresh window only", () => {
|
||||
const fresh = makeEvent({ id: "fresh" });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: undefined,
|
||||
snapshot: null,
|
||||
history: [fresh],
|
||||
});
|
||||
assert.deepEqual(
|
||||
merged.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
auxBackfillWindow.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Per-channel persisted message snapshots.
|
||||
*
|
||||
* A channel revisited after its React-Query cache entry is gone (app restart,
|
||||
* gcTime expiry, workspace remount) goes fully cold and holds a skeleton for a
|
||||
* relay round trip. This module persists the newest slice of each channel's
|
||||
* timeline so a revisit can paint instantly from the snapshot while the
|
||||
* history fetch revalidates behind it — the same stale-then-revalidate pattern
|
||||
* the sidebar's channelSnapshot uses for the channel list.
|
||||
*
|
||||
* Keyed per relay URL + channel id so one relay's messages never bleed into
|
||||
* another. Bounded two ways: only the newest MAX_EVENTS_PER_SNAPSHOT events
|
||||
* per channel, and only the MAX_CHANNELS_PER_RELAY most recently written
|
||||
* channels per relay (older ones are evicted LRU on write).
|
||||
*/
|
||||
|
||||
import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1";
|
||||
|
||||
// Newest events kept per channel. The trailing slice of the sorted timeline
|
||||
// cache, so recent auxiliary events (reactions/edits) ride along with the
|
||||
// content rows they decorate.
|
||||
const MAX_EVENTS_PER_SNAPSHOT = 80;
|
||||
|
||||
const MAX_CHANNELS_PER_RELAY = 20;
|
||||
|
||||
export function messageSnapshotKey(relayUrl: string, channelId: string) {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:${channelId}`;
|
||||
}
|
||||
|
||||
type SnapshotPayload = {
|
||||
version: 1;
|
||||
updatedAt: number;
|
||||
events: RelayEvent[];
|
||||
};
|
||||
|
||||
function parseSnapshotPayload(json: unknown): SnapshotPayload | null {
|
||||
if (typeof json !== "object" || json === null) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
if (obj.version !== 1 || !Array.isArray(obj.events)) return null;
|
||||
const updatedAt =
|
||||
typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt)
|
||||
? obj.updatedAt
|
||||
: 0;
|
||||
return { version: 1, updatedAt, events: obj.events as RelayEvent[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted message snapshot for a channel, or null when absent or
|
||||
* malformed.
|
||||
*/
|
||||
export function readMessageSnapshot(
|
||||
relayUrl: string,
|
||||
channelId: string,
|
||||
): RelayEvent[] | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
messageSnapshotKey(relayUrl, channelId),
|
||||
);
|
||||
if (!raw) return null;
|
||||
const parsed = parseSnapshotPayload(JSON.parse(raw));
|
||||
if (!parsed || parsed.events.length === 0) return null;
|
||||
return parsed.events;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function relayPrefix(relayUrl: string) {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`;
|
||||
}
|
||||
|
||||
function collectKeysWithPrefix(prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function evictOldestSnapshots(prefix: string, keepingKey: string) {
|
||||
const others = collectKeysWithPrefix(prefix).filter(
|
||||
(key) => key !== keepingKey,
|
||||
);
|
||||
if (others.length < MAX_CHANNELS_PER_RELAY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byAge = others
|
||||
.map((key) => {
|
||||
let updatedAt = 0;
|
||||
try {
|
||||
const parsed = parseSnapshotPayload(
|
||||
JSON.parse(window.localStorage.getItem(key) ?? ""),
|
||||
);
|
||||
updatedAt = parsed?.updatedAt ?? 0;
|
||||
} catch {
|
||||
// Malformed entries sort oldest and get evicted first.
|
||||
}
|
||||
return { key, updatedAt };
|
||||
})
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
|
||||
for (const { key } of byAge.slice(
|
||||
0,
|
||||
others.length - (MAX_CHANNELS_PER_RELAY - 1),
|
||||
)) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the newest slice of a channel's timeline. Pending optimistic events
|
||||
* are dropped (they have no relay identity to revalidate against). Skips the
|
||||
* write when unchanged so live-append churn does not re-serialize an identical
|
||||
* snapshot. Non-fatal on storage failure (e.g. quota exceeded).
|
||||
*/
|
||||
export function writeMessageSnapshot(
|
||||
relayUrl: string,
|
||||
channelId: string,
|
||||
events: RelayEvent[],
|
||||
): void {
|
||||
try {
|
||||
const persistable = events
|
||||
.filter((event) => !event.pending)
|
||||
.slice(-MAX_EVENTS_PER_SNAPSHOT);
|
||||
if (persistable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = messageSnapshotKey(relayUrl, channelId);
|
||||
const previous = window.localStorage.getItem(key);
|
||||
if (previous) {
|
||||
const parsed = parseSnapshotPayload(JSON.parse(previous));
|
||||
if (
|
||||
parsed &&
|
||||
JSON.stringify(parsed.events) === JSON.stringify(persistable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
evictOldestSnapshots(relayPrefix(relayUrl), key);
|
||||
window.localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
events: persistable,
|
||||
} satisfies SnapshotPayload),
|
||||
);
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a fresh history window over the in-memory cache — or, when cold, over
|
||||
* the persisted snapshot — and pick the window aux backfill must cover.
|
||||
*
|
||||
* The snapshot can hold events older than the fetch window; dropping them on
|
||||
* settle would visibly shrink an already-painted timeline, so the merge keeps
|
||||
* them. But a kept snapshot row deleted/edited while the app was closed never
|
||||
* reappears in any history fetch (the relay soft-deletes), so its tombstone or
|
||||
* edit is only reachable by `#e` over that row's id — cold snapshot loads must
|
||||
* therefore backfill over the merged timeline, not just the fresh window.
|
||||
* Otherwise the ghost paints, and the post-settle snapshot rewrite persists it
|
||||
* forever.
|
||||
*/
|
||||
export function mergeHistoryOverSnapshot(input: {
|
||||
cached: RelayEvent[] | undefined;
|
||||
snapshot: RelayEvent[] | null;
|
||||
history: RelayEvent[];
|
||||
}): { merged: RelayEvent[]; auxBackfillWindow: RelayEvent[] } {
|
||||
const usedSnapshot = !input.cached && input.snapshot !== null;
|
||||
const merged = mergeTimelineHistoryMessages(
|
||||
input.cached ?? input.snapshot ?? [],
|
||||
input.history,
|
||||
);
|
||||
return { merged, auxBackfillWindow: usedSnapshot ? merged : input.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every channel message snapshot for a relay. Called when a workspace
|
||||
* is removed.
|
||||
*/
|
||||
export function removeMessageSnapshotsForRelay(relayUrl: string): void {
|
||||
try {
|
||||
for (const key of collectKeysWithPrefix(relayPrefix(relayUrl))) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export type PageOlderResult = {
|
||||
hasOlderMessages: boolean;
|
||||
};
|
||||
|
||||
// One paging pass per channel at a time: the background cold-load top-up and
|
||||
// a scroll-up fetch share the running pass instead of overlapping REQs.
|
||||
const inFlightPasses = new Map<string, Promise<PageOlderResult>>();
|
||||
|
||||
/**
|
||||
* Seed the bridge keyset cursor for the dense-second escape hatch.
|
||||
*
|
||||
@@ -157,7 +161,26 @@ async function drainOlderViaKeyset(args: {
|
||||
* `shouldContinue` lets the caller bail mid-pass (e.g. channel switch). Returns
|
||||
* whether more history is believed to remain.
|
||||
*/
|
||||
export async function pageOlderMessagesUntilRowFloor(
|
||||
export function pageOlderMessagesUntilRowFloor(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
shouldContinue: () => boolean,
|
||||
): Promise<PageOlderResult> {
|
||||
const inFlight = inFlightPasses.get(channelId);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const pass = runPageOlderPass(queryClient, channelId, shouldContinue).finally(
|
||||
() => {
|
||||
inFlightPasses.delete(channelId);
|
||||
},
|
||||
);
|
||||
inFlightPasses.set(channelId, pass);
|
||||
return pass;
|
||||
}
|
||||
|
||||
async function runPageOlderPass(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
shouldContinue: () => boolean,
|
||||
|
||||
@@ -85,6 +85,29 @@ test("initial load holds the skeleton while the cold-load top-up fetches", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("pre-settle placeholder rows paint immediately (snapshot revisit)", () => {
|
||||
// A revisit painting from the React-Query cache or a persisted snapshot:
|
||||
// placeholder rows are a previously-settled timeline, not a partial cold
|
||||
// load — show them stale-then-revalidate instead of a skeleton.
|
||||
assert.equal(
|
||||
selectTimelineLoadingState(
|
||||
{ ...settled, isFetching: true, isPlaceholderData: true, dataLength: 8 },
|
||||
false,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("pre-settle EMPTY placeholder still holds the skeleton", () => {
|
||||
assert.equal(
|
||||
selectTimelineLoadingState(
|
||||
{ ...settled, isFetching: true, isPlaceholderData: true, dataLength: 0 },
|
||||
false,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("settled channel with rows mid-refetch is not loading", () => {
|
||||
// Same query shape, but after first settle: the latch owns refetch blips, so
|
||||
// present rows mean loaded.
|
||||
|
||||
@@ -22,12 +22,16 @@ export function selectTimelineLoadingState(
|
||||
if (status.isPending) {
|
||||
return true;
|
||||
}
|
||||
// Before the first settle, hold the skeleton for the whole cold load — the
|
||||
// row-floor top-up keeps `isFetching` true after the cache already has rows,
|
||||
// and dropping the skeleton there exposes the older-fetch spinner on first
|
||||
// load. After settle, the latch protects against refetch blips, so once real
|
||||
// rows are present we are loaded even mid-refetch.
|
||||
if (!hasSettled) {
|
||||
// Placeholder rows are a previously-settled timeline (React-Query cache on
|
||||
// revisit, or a persisted snapshot) — paint them stale-then-revalidate
|
||||
// instead of holding a skeleton over known content.
|
||||
if (status.isPlaceholderData && (status.dataLength ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise hold the skeleton for the whole cold load: the live
|
||||
// subscription can seed a few rows before the history fetch settles, and
|
||||
// painting those as if loaded flashes a near-empty timeline.
|
||||
return status.isFetching;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -22,16 +22,14 @@ describe("workspaceInitials", () => {
|
||||
});
|
||||
|
||||
describe("workspaceRailIndicators", () => {
|
||||
it("shows a dot for an observed workspace with unread", () => {
|
||||
it("shows no badge for an observed workspace with unread but no mentions", () => {
|
||||
const r = workspaceRailIndicators({ hasUnread: true, state: "ready" });
|
||||
assert.equal(r.showDot, true);
|
||||
assert.equal(r.showBadge, false);
|
||||
assert.equal(r.pending, false);
|
||||
});
|
||||
|
||||
it("shows no dot for an observed workspace with no unread", () => {
|
||||
it("shows no badge for an observed workspace with no unread", () => {
|
||||
const r = workspaceRailIndicators({ hasUnread: false, state: "ready" });
|
||||
assert.equal(r.showDot, false);
|
||||
assert.equal(r.showBadge, false);
|
||||
assert.equal(r.pending, false);
|
||||
});
|
||||
@@ -42,7 +40,6 @@ describe("workspaceRailIndicators", () => {
|
||||
count: 3,
|
||||
state: "ready",
|
||||
});
|
||||
assert.equal(r.showDot, true);
|
||||
assert.equal(r.showBadge, true);
|
||||
assert.equal(r.mentionCount, 3);
|
||||
assert.equal(r.badgeLabel, "3");
|
||||
@@ -57,13 +54,12 @@ describe("workspaceRailIndicators", () => {
|
||||
assert.equal(r.badgeLabel, "99+");
|
||||
});
|
||||
|
||||
it("never reports unread for an unobserved (unknown) workspace", () => {
|
||||
it("never reports mentions for an unobserved (unknown) workspace", () => {
|
||||
const r = workspaceRailIndicators({
|
||||
hasUnread: true,
|
||||
count: 5,
|
||||
state: "unknown",
|
||||
});
|
||||
assert.equal(r.showDot, false);
|
||||
assert.equal(r.showBadge, false);
|
||||
assert.equal(r.mentionCount, 0);
|
||||
assert.equal(r.pending, true);
|
||||
@@ -72,16 +68,15 @@ describe("workspaceRailIndicators", () => {
|
||||
it("treats loading as pending, not as no-unread", () => {
|
||||
const r = workspaceRailIndicators({ hasUnread: false, state: "loading" });
|
||||
assert.equal(r.pending, true);
|
||||
assert.equal(r.showDot, false);
|
||||
assert.equal(r.showBadge, false);
|
||||
});
|
||||
|
||||
it("never reports unread on an errored observation", () => {
|
||||
it("never reports mentions on an errored observation", () => {
|
||||
const r = workspaceRailIndicators({
|
||||
hasUnread: true,
|
||||
count: 2,
|
||||
state: "error",
|
||||
});
|
||||
assert.equal(r.showDot, false);
|
||||
assert.equal(r.showBadge, false);
|
||||
assert.equal(r.mentionCount, 0);
|
||||
assert.equal(r.pending, false);
|
||||
|
||||
@@ -28,12 +28,11 @@ export function workspaceInitials(name: string): string {
|
||||
|
||||
/**
|
||||
* Presentation decisions for one workspace button, derived from its observed
|
||||
* unread state. Pure so it can be unit-tested without a DOM. The `state` guard
|
||||
* ensures we NEVER render a "no unread" affordance for a relay we could not
|
||||
* observe (`unknown`/`loading`/`error`) — only a `ready` observation is trusted.
|
||||
* mention state. Pure so it can be unit-tested without a DOM. The `state` guard
|
||||
* ensures we NEVER render a mention badge for a relay we could not observe
|
||||
* (`unknown`/`loading`/`error`) — only a `ready` observation is trusted.
|
||||
*/
|
||||
export function workspaceRailIndicators(unread: WorkspaceUnreadState): {
|
||||
showDot: boolean;
|
||||
mentionCount: number;
|
||||
showBadge: boolean;
|
||||
pending: boolean;
|
||||
@@ -43,7 +42,6 @@ export function workspaceRailIndicators(unread: WorkspaceUnreadState): {
|
||||
const mentionCount = observed ? (unread.count ?? 0) : 0;
|
||||
const showBadge = mentionCount > 0;
|
||||
return {
|
||||
showDot: observed && unread.hasUnread,
|
||||
mentionCount,
|
||||
showBadge,
|
||||
pending: unread.state === "unknown" || unread.state === "loading",
|
||||
@@ -63,7 +61,7 @@ function WorkspaceButton({
|
||||
unread: WorkspaceUnreadState;
|
||||
onSwitch: () => void;
|
||||
}) {
|
||||
const { showDot, mentionCount, showBadge, pending, badgeLabel } =
|
||||
const { mentionCount, showBadge, pending, badgeLabel } =
|
||||
workspaceRailIndicators(unread);
|
||||
|
||||
const tooltipLabel = showBadge
|
||||
@@ -81,18 +79,6 @@ function WorkspaceButton({
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
>
|
||||
{/* Active/unread indicator pill on the left edge (Discord-style). */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-[-6px] w-1 rounded-r-full bg-primary transition-all",
|
||||
isActive
|
||||
? "h-6 opacity-100"
|
||||
: showDot
|
||||
? "h-2 opacity-100"
|
||||
: "h-2 opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold transition-all",
|
||||
@@ -106,7 +92,7 @@ function WorkspaceButton({
|
||||
</span>
|
||||
{showBadge ? (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-2xs font-semibold text-destructive-foreground ring-2 ring-sidebar"
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-2xs font-semibold text-primary-foreground ring-2 ring-sidebar"
|
||||
data-testid={`workspace-rail-mentions-${workspace.id}`}
|
||||
>
|
||||
{badgeLabel}
|
||||
@@ -121,8 +107,8 @@ function WorkspaceButton({
|
||||
|
||||
/**
|
||||
* Discord/Slack-style vertical rail of workspaces on the far left of the app.
|
||||
* Shows an unread dot and a mention-count badge for inactive workspaces
|
||||
* (observed via `useWorkspaceUnread`) and switches relays on click.
|
||||
* Shows a mention-count badge for inactive workspaces (observed via
|
||||
* `useWorkspaceUnread`) and switches relays on click.
|
||||
*
|
||||
* Hidden entirely with a single workspace — a rail of one adds no value.
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "./workspaceStorage";
|
||||
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
|
||||
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
|
||||
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
|
||||
|
||||
export type UseWorkspacesReturn = {
|
||||
workspaces: Workspace[];
|
||||
@@ -118,6 +119,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
|
||||
if (removed) {
|
||||
removeSelfProfileCachesForRelay(removed.relayUrl);
|
||||
removeChannelSnapshotForRelay(removed.relayUrl);
|
||||
removeMessageSnapshotsForRelay(removed.relayUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ async function getMessagePosition(
|
||||
}, messageId);
|
||||
}
|
||||
|
||||
test("first channel load holds skeleton instead of showing older-history spinner", async ({
|
||||
test("first channel load paints the first window without waiting for the row-floor top-up", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
@@ -93,22 +93,23 @@ test("first channel load holds skeleton instead of showing older-history spinner
|
||||
|
||||
window.__BUZZ_E2E__ = {
|
||||
...window.__BUZZ_E2E__,
|
||||
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 1_500 },
|
||||
mock: { ...window.__BUZZ_E2E__?.mock, historyDelayMs: 5_000 },
|
||||
};
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// The reply-heavy window renders < MIN_TOP_LEVEL_ROWS_PER_FETCH top-level
|
||||
// rows, which triggers the row-floor top-up — paced at 5s per page above.
|
||||
// First paint must NOT wait for it: rows appear well inside that window,
|
||||
// proving the top-up runs in the background rather than gating the queryFn.
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator(".t-skel-bar").first()).toBeVisible();
|
||||
await expect(page.getByTestId("message-timeline-fetching-older")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
timeout: 4_000,
|
||||
});
|
||||
// And once rows have painted, the cold-load skeleton must be gone.
|
||||
await expect(timeline.locator(".t-skel-bar")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("preserves user scroll while older channel history loads", async ({
|
||||
|
||||
Reference in New Issue
Block a user