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:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-02 12:06:43 -04:00
co-authored by Tyler Longwell
10 changed files with 543 additions and 61 deletions
+64 -16
View File
@@ -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);
}
}
+9 -8
View File
@@ -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 ({