diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 960be8117..ffd6e96e5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -641,7 +641,7 @@ export function AppShell() { useAppShellLifecycleEffects({ desktopBadgeEnabled: !isHuddleRoom, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, }); // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index ae62ae3e2..969bf67ca 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -8,14 +8,14 @@ import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers"; type AppShellLifecycleEffectsOptions = { desktopBadgeEnabled: boolean; homeBadgeCountExcludingHighPriority: number; - unreadChannelIds: ReadonlySet; + topLevelUnreadChannelIds: ReadonlySet; unreadChannelNotificationCount: number; }; export function useAppShellLifecycleEffects({ desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, }: AppShellLifecycleEffectsOptions) { // Event-driven reconnect: network online / focus / visibility short-circuit @@ -82,12 +82,12 @@ export function useAppShellLifecycleEffects({ void setDesktopAppBadge( count ? { kind: "count", count } - : { kind: unreadChannelIds.size ? "dot" : "none" }, + : { kind: topLevelUnreadChannelIds.size ? "dot" : "none" }, ); }, [ desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, - unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelNotificationCount, ]); } diff --git a/desktop/src/features/channels/observedUnreadNative.test.mjs b/desktop/src/features/channels/observedUnreadNative.test.mjs index eea4fbb33..0f443affc 100644 --- a/desktop/src/features/channels/observedUnreadNative.test.mjs +++ b/desktop/src/features/channels/observedUnreadNative.test.mjs @@ -52,6 +52,86 @@ async function settle() { }); } +test("native no-op marker delta does not notify the renderer", async () => { + installFreshStorage(); + let harness; + let notifications = 0; + const rig = installNativeRig(); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: "pk-no-op-marker", + getTs: () => NOW_S, + onPruned: () => { + notifications += 1; + }, + }, + makeRefs(), + ); + await settle(); + assert.equal(notifications, 1, "opening the native snapshot notifies once"); + + harness.api.syncMarkers(["channel-empty"]); + await settle(); + + assert.equal( + rig.requests("observed_unread_ingest").length, + 1, + "the marker must still advance the native revision and ack sequence", + ); + assert.equal( + notifications, + 1, + "an empty projection delta must not trigger a renderer feedback render", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + +test("native snapshotRequired response still reopens and notifies", async () => { + installFreshStorage(); + let harness; + let notifications = 0; + const scope = { pubkey: "pk-snapshot-required", relayUrl: RELAY }; + const rig = installNativeRig(); + try { + harness = await mountHook( + { + ...DEFAULT_PROPS, + pubkey: scope.pubkey, + getTs: () => NOW_S, + onPruned: () => { + notifications += 1; + }, + }, + makeRefs(), + ); + await settle(); + rig.scope(scope).lastSequence = -1; + + harness.api.syncMarkers(["channel-gap"]); + await settle(); + await settle(); + + assert.equal( + rig.requests("observed_unread_open_scope").length, + 2, + "a sequence gap must reopen the scope even when it carries no projection rows", + ); + assert.equal( + notifications, + 2, + "the replacement snapshot must still notify the renderer", + ); + } finally { + await harness?.unmount(); + rig.restore(); + } +}); + // ── Entry: the boundary that makes every other test meaningful ──────────────── test("native mode is ENTERED: the hook opens the scope over the bridge", async () => { diff --git a/desktop/src/features/channels/useObservedUnreadPersistence.ts b/desktop/src/features/channels/useObservedUnreadPersistence.ts index d27a643b5..71960e1c4 100644 --- a/desktop/src/features/channels/useObservedUnreadPersistence.ts +++ b/desktop/src/features/channels/useObservedUnreadPersistence.ts @@ -170,7 +170,9 @@ export function useObservedUnreadPersistence( revision: response.revision, sequence: response.ackedSequence, }; - optionsRef.current.onPruned?.(); + if (response.removed.length > 0 || response.upserts.length > 0) { + optionsRef.current.onPruned?.(); + } }, [reopen], ); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b27bf91ec..cb90d7642 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10,6 +10,10 @@ import { handleDeleteCustomHarness, } from "./e2eBridgeCustomHarnesses.ts"; +import type { + ObservedUnreadProjection, + ObservedUnreadResponse, +} from "@/shared/api/tauriObservedUnread"; import type { UnreadCatchUpChannelResult } from "@/shared/api/tauriUnreadCatchUp"; import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; @@ -3078,6 +3082,111 @@ type MockSaveSubscriptionRow = { }; let mockSaveSubscriptions: MockSaveSubscriptionRow[] = []; +type MockObservedUnreadScope = { + generation: string; + revision: number; + lastSequence: number; + migrationComplete: boolean; + events: Map< + string, + { + channelId: string; + id: string; + createdAt: number; + rootId: string | null; + highPriority: boolean; + countsTowardBadge: boolean; + countsTowardAppBadge: boolean; + } + >; + channelLatest: Map; + markers: Map; +}; + +const mockObservedUnreadScopes = new Map(); + +function mockObservedUnreadScopeKey(scope: { + pubkey: string; + relayUrl: string; +}) { + return `${scope.pubkey.trim().toLowerCase()}:${scope.relayUrl + .trim() + .replace(/\/+$/, "")}`; +} + +function getMockObservedUnreadScope(scope: { + pubkey: string; + relayUrl: string; +}) { + const key = mockObservedUnreadScopeKey(scope); + const existing = mockObservedUnreadScopes.get(key); + if (existing) return existing; + const created: MockObservedUnreadScope = { + generation: "e2e", + revision: 0, + lastSequence: 0, + migrationComplete: false, + events: new Map(), + channelLatest: new Map(), + markers: new Map(), + }; + mockObservedUnreadScopes.set(key, created); + return created; +} + +function mockObservedUnreadProjections( + scope: MockObservedUnreadScope, +): ObservedUnreadProjection[] { + const channels = new Map(); + for (const [channelId, latest] of scope.channelLatest) { + channels.set(channelId, { + channelId, + latest, + count: 0, + badgeCount: 0, + appBadgeCount: 0, + topLevelUnread: false, + highPriorityUnread: false, + }); + } + for (const event of scope.events.values()) { + let readAt = Math.max( + scope.markers.get(event.channelId) ?? 0, + scope.markers.get(`msg:${event.id}`) ?? 0, + ); + if (event.rootId) { + readAt = Math.max( + readAt, + scope.markers.get(`thread:${event.rootId}`) ?? 0, + ); + } + if (event.createdAt <= readAt) continue; + const projection = channels.get(event.channelId) ?? { + channelId: event.channelId, + latest: 0, + count: 0, + badgeCount: 0, + appBadgeCount: 0, + topLevelUnread: false, + highPriorityUnread: false, + }; + projection.latest = Math.max(projection.latest, event.createdAt); + projection.count += 1; + projection.badgeCount += event.countsTowardBadge ? 1 : 0; + projection.appBadgeCount += event.countsTowardAppBadge ? 1 : 0; + projection.topLevelUnread ||= event.rootId === null; + projection.highPriorityUnread ||= event.highPriority; + channels.set(event.channelId, projection); + } + return [...channels.values()].sort((left, right) => + left.channelId.localeCompare(right.channelId), + ); +} + +function resetMockObservedUnread() { + mockObservedUnreadScopes.clear(); +} + function resetMockSaveSubscriptions(config: E2eConfig | undefined) { mockSaveSubscriptions = (config?.mock?.saveSubscriptions ?? []).map((s) => ({ ...s, @@ -3094,6 +3203,123 @@ function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) { } } +function mockPersonaCatalogPublications() { + const publications = []; + const claimed = new Set(); + for (const event of [...mockPersonaEvents].sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + )) { + const dTags = event.tags.filter((tag) => tag[0] === "d"); + if (dTags.length !== 1 || !dTags[0]?.[1]) continue; + const sourcePersonaId = dTags[0][1]; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${sourcePersonaId}`; + if (claimed.has(coordinate)) continue; + claimed.add(coordinate); + if (!personaHasExactSharedTag(event)) continue; + let content: Record; + try { + content = JSON.parse(event.content) as Record; + } catch { + continue; + } + const displayName = content.display_name; + const systemPrompt = content.system_prompt ?? ""; + const optionalString = (value: unknown) => + typeof value === "string" && value.trim() ? value : null; + const isExtendedPictographic = (value: string) => + /^\p{Extended_Pictographic}$/u.test(value); + const hasValidVisibleText = (value: string, allowLayout: boolean) => { + const characters = [...value]; + return characters.every((character, index) => { + if (allowLayout && (character === "\n" || character === "\t")) + return true; + if (/\p{Control}/u.test(character)) return false; + const codepoint = character.codePointAt(0) ?? 0; + const defaultIgnorable = + codepoint === 0x00ad || + codepoint === 0x034f || + codepoint === 0x061c || + (codepoint >= 0x115f && codepoint <= 0x1160) || + (codepoint >= 0x17b4 && codepoint <= 0x17b5) || + (codepoint >= 0x180b && codepoint <= 0x180f) || + (codepoint >= 0x200b && codepoint <= 0x200f) || + (codepoint >= 0x202a && codepoint <= 0x202e) || + (codepoint >= 0x2060 && codepoint <= 0x206f) || + codepoint === 0x3164 || + (codepoint >= 0xfe00 && codepoint <= 0xfe0f) || + codepoint === 0xfeff || + codepoint === 0xffa0 || + (codepoint >= 0xfff0 && codepoint <= 0xfff8) || + (codepoint >= 0x1bca0 && codepoint <= 0x1bca3) || + (codepoint >= 0x1d173 && codepoint <= 0x1d17a) || + (codepoint >= 0xe0000 && codepoint <= 0xe0fff); + if (!defaultIgnorable) return true; + if (character === "\ufe0f") { + const previous = characters[index - 1]; + return ( + previous !== undefined && + (/^[#*0-9]$/u.test(previous) || isExtendedPictographic(previous)) + ); + } + if (character !== "\u200d") return false; + let previousIndex = index - 1; + while ( + previousIndex >= 0 && + (characters[previousIndex] === "\ufe0f" || + /[\u{1f3fb}-\u{1f3ff}]/u.test(characters[previousIndex] ?? "")) + ) { + previousIndex -= 1; + } + return ( + previousIndex >= 0 && + isExtendedPictographic(characters[previousIndex] ?? "") && + isExtendedPictographic(characters[index + 1] ?? "") + ); + }); + }; + if ( + typeof displayName !== "string" || + !displayName.trim() || + [...displayName].length > 128 || + typeof systemPrompt !== "string" || + new TextEncoder().encode(systemPrompt).length > 64 * 1024 || + !hasValidVisibleText(displayName, false) || + !hasValidVisibleText(systemPrompt, true) + ) + continue; + publications.push({ + eventId: event.id, + ownerPubkey, + sourcePersonaId, + createdAt: event.created_at, + agent: { + displayName, + avatarUrl: optionalString(content.avatar_url), + systemPrompt, + runtime: optionalString(content.runtime), + model: optionalString(content.model), + provider: optionalString(content.provider), + namePool: Array.isArray(content.name_pool) + ? content.name_pool.filter( + (value): value is string => typeof value === "string", + ) + : [], + respondTo: + content.respond_to === "allowlist" + ? "owner-only" + : content.respond_to === "owner-only" || + content.respond_to === "anyone" + ? content.respond_to + : null, + parallelism: + typeof content.parallelism === "number" ? content.parallelism : null, + }, + }); + } + return publications; +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -10267,6 +10493,7 @@ export function maybeInstallE2eTauriMocks() { resetMockMesh(); resetMockUserStatuses(); resetMockPersonaCatalogEvents(config); + resetMockObservedUnread(); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); @@ -13377,43 +13604,144 @@ export function maybeInstallE2eTauriMocks() { case "start_archive_sync": case "stop_archive_sync": return null; + case "fetch_persona_catalog": + return mockPersonaCatalogPublications(); case "observed_unread_open_scope": { const request = payload as { - request: { scope: { pubkey: string; relayUrl: string } }; + request: { + scope: { pubkey: string; relayUrl: string }; + legacyPayload?: { + eventsByChannel?: Record< + string, + Array<{ + id: string; + createdAt: number; + rootId?: string | null; + highPriority: boolean; + countsTowardBadge: boolean; + countsTowardAppBadge: boolean; + }> + >; + }; + }; }; + const scope = getMockObservedUnreadScope(request.request.scope); + if (!scope.migrationComplete) { + for (const [channelId, events] of Object.entries( + request.request.legacyPayload?.eventsByChannel ?? {}, + )) { + for (const event of events) { + if (!scope.events.has(event.id)) { + scope.events.set(event.id, { + channelId, + ...event, + rootId: event.rootId ?? null, + }); + } + } + } + scope.migrationComplete = true; + } return { kind: "snapshot", scope: request.request.scope, - generation: "e2e", - revision: 0, - lastAckedSequence: 0, - migrationComplete: true, + generation: scope.generation, + revision: scope.revision, + lastAckedSequence: scope.lastSequence, + migrationComplete: scope.migrationComplete, membershipSeeded: true, - channels: [], - }; + channels: mockObservedUnreadProjections(scope), + } satisfies ObservedUnreadResponse; } case "observed_unread_ingest": { - const request = payload as { + const { request } = payload as { request: { scope: { pubkey: string; relayUrl: string }; sequence: number; baseRevision: number; + events: Array<{ + channelId: string; + id: string; + createdAt: number; + rootId: string | null; + highPriority: boolean; + countsTowardBadge: boolean; + countsTowardAppBadge: boolean; + }>; + channelLatest: Array<{ channelId: string; createdAt: number }>; + markers: Array<{ contextId: string; readAt: number | null }>; + membership: Array<{ + kind: string; + value: string; + present: boolean; + }>; + clearChannels: string[]; + clearAll: boolean; }; }; + const scope = getMockObservedUnreadScope(request.scope); + const before = new Map( + mockObservedUnreadProjections(scope).map((item) => [ + item.channelId, + item, + ]), + ); + if (request.clearAll) { + scope.events.clear(); + scope.channelLatest.clear(); + } + for (const channelId of request.clearChannels) { + scope.channelLatest.delete(channelId); + for (const [id, event] of scope.events) { + if (event.channelId === channelId) scope.events.delete(id); + } + } + for (const latest of request.channelLatest ?? []) { + scope.channelLatest.set( + latest.channelId, + Math.max( + scope.channelLatest.get(latest.channelId) ?? 0, + latest.createdAt, + ), + ); + } + for (const event of request.events ?? []) { + if (!scope.events.has(event.id)) scope.events.set(event.id, event); + } + for (const marker of request.markers ?? []) { + if (marker.readAt === null) scope.markers.delete(marker.contextId); + else + scope.markers.set( + marker.contextId, + Math.max(scope.markers.get(marker.contextId) ?? 0, marker.readAt), + ); + } + const after = mockObservedUnreadProjections(scope); + const afterIds = new Set(after.map((item) => item.channelId)); + const baseRevision = scope.revision; + scope.revision += 1; + scope.lastSequence = request.sequence; return { kind: "delta", - scope: request.request.scope, - generation: "e2e", - baseRevision: request.request.baseRevision, - revision: request.request.baseRevision + 1, - ackedSequence: request.request.sequence, - upserts: [], - removed: [], - }; + scope: request.scope, + generation: scope.generation, + baseRevision, + revision: scope.revision, + ackedSequence: request.sequence, + upserts: after.filter( + (item) => + JSON.stringify(before.get(item.channelId)) !== + JSON.stringify(item), + ), + removed: [...before.keys()].filter((id) => !afterIds.has(id)), + } satisfies ObservedUnreadResponse; } case "unread_catch_up": { const request = payload as { - request: { channels: Array<{ id: string }> }; + request: { + channels: Array<{ id: string }>; + selfPubkey: string; + }; }; const results: UnreadCatchUpChannelResult[] = request.request.channels.map((channel) => ({ @@ -13422,7 +13750,18 @@ export function maybeInstallE2eTauriMocks() { observedEvents: [], maxTrigger: 0, activityRows: [], - discovered: { participated: [], authored: [], mentioned: [] }, + discovered: { + participated: [], + authored: getMockMessageStore(channel.id) + .filter( + (event) => + event.pubkey === request.request.selfPubkey && + getThreadReferenceFromTags(event.tags).parentEventId === + null, + ) + .map((event) => event.id), + mentioned: [], + }, })); // Keep this mock aligned with the complete Rust serde shape pinned by // `serialized_response_matches_the_typescript_contract`.