From bcd6cf84df97f958466b5b68dc1d96e7796055ea Mon Sep 17 00:00:00 2001 From: Gener <39689863+GenerQAQ@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:09:39 +0800 Subject: [PATCH 1/3] fix(web): let layouts own qualified channel main views --- .../src/app/c/channels/[serverId]/page.tsx | 16 +- src/web/src/app/c/layout.dom.test.ts | 5 +- .../c/me/[dmId]/page.dismissal.dom.test.ts | 52 +- src/web/src/app/c/me/[dmId]/page.test.ts | 16 +- src/web/src/app/c/me/[dmId]/page.tsx | 555 +-------------- .../app/c/me/layout.route-memory.dom.test.ts | 48 +- src/web/src/app/c/me/layout.tsx | 56 +- ...aptive-navigation-cutover.contract.test.ts | 2 +- ...annel-route.text-scroll-target.dom.test.ts | 5 +- .../community/channels/channel-route.tsx | 7 +- .../conversation-resolution-error-frame.tsx | 6 +- .../community/channels/dm-route.dom.test.ts | 77 ++ .../community/channels/dm-route.tsx | 45 ++ .../components/community/channels/dm-view.tsx | 556 +++++++++++++++ ...achment-download-surfaces.contract.test.ts | 2 +- .../messages/composer-facade.contract.test.ts | 4 +- .../message-link-surfaces.contract.test.ts | 4 +- .../message-list-facade.contract.test.ts | 2 +- .../use-shell-rail-controller.dom.test.ts | 15 +- .../shell/use-shell-rail-controller.ts | 16 +- .../hooks/community/channel-metadata.test.ts | 6 + .../src/hooks/community/channel-metadata.ts | 69 +- .../use-channel-metadata.dom.test.ts | 150 ++++ .../hooks/community/use-channel-metadata.ts | 49 ++ ...annel-route-model.subscription.dom.test.ts | 28 +- .../community/use-channel-route-model.ts | 52 +- .../hooks/community/use-child-channel-meta.ts | 41 +- .../hooks/community/use-dm-read-state.test.ts | 4 +- .../src/hooks/community/use-dm-read-state.ts | 39 +- .../use-dm-route-verification.dom.test.ts | 670 ++++-------------- .../community/use-dm-route-verification.ts | 131 +--- src/web/src/lib/community-db/sync.ts | 47 +- .../conversation-navigation-warmup.test.ts | 4 +- .../conversation-navigation-warmup.ts | 2 +- .../src/lib/community/last-channel.test.ts | 51 +- src/web/src/lib/community/last-channel.ts | 52 +- .../community/last-community-route.test.ts | 13 + .../src/lib/community/last-community-route.ts | 10 + .../lib/community/last-me-location.test.ts | 45 -- src/web/src/lib/community/last-me-location.ts | 34 +- src/web/src/lib/query-keys.test.ts | 5 +- src/web/src/lib/query-keys.ts | 7 +- .../community-channel-route-contract.test.ts | 2 +- src/web/src/test/e2e-ui/04-dm.spec.ts | 29 +- 44 files changed, 1452 insertions(+), 1577 deletions(-) create mode 100644 src/web/src/components/community/channels/dm-route.dom.test.ts create mode 100644 src/web/src/components/community/channels/dm-route.tsx create mode 100644 src/web/src/components/community/channels/dm-view.tsx create mode 100644 src/web/src/hooks/community/use-channel-metadata.dom.test.ts create mode 100644 src/web/src/hooks/community/use-channel-metadata.ts diff --git a/src/web/src/app/c/channels/[serverId]/page.tsx b/src/web/src/app/c/channels/[serverId]/page.tsx index c23c58aa4..03b03d19c 100644 --- a/src/web/src/app/c/channels/[serverId]/page.tsx +++ b/src/web/src/app/c/channels/[serverId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation" import { ServerLandingPendingFrame } from "@/components/community/shell/server-landing-pending-frame" import { useServer } from "@/hooks/community/use-servers" import { useBreakpoint } from "@/hooks/use-mobile" -import { getLastChannel, pickServerLandingChannel } from "@/lib/community/last-channel" +import { getLastChannel, resolveCommunityLandingHref } from "@/lib/community/last-channel" export default function ServerDefaultPage() { const params = useParams<{ serverId: string }>() @@ -21,13 +21,15 @@ export default function ServerDefaultPage() { const allChannels = currentServer.categories.flatMap((cat) => cat.channels) // Restore one remembered channel id, or use the first top-level channel // when there is no valid memory. - const target = pickServerLandingChannel( - allChannels.map((c) => c.id), - getLastChannel(serverId), - ) - if (target) { + const target = resolveCommunityLandingHref({ + serverId, + channelIds: allChannels.filter((channel) => !channel.pending).map((channel) => channel.id), + last: getLastChannel(serverId), + breakpoint, + }) + if (target !== `/c/channels/${encodeURIComponent(serverId)}`) { const search = searchParams.toString() - const href = `/c/channels/${serverId}/${target}${search ? `?${search}` : ""}` + const href = `${target}${search ? `?${search}` : ""}` if (replacingHrefRef.current === href) return replacingHrefRef.current = href router.replace(href) diff --git a/src/web/src/app/c/layout.dom.test.ts b/src/web/src/app/c/layout.dom.test.ts index 37c12b4ed..c48131f03 100644 --- a/src/web/src/app/c/layout.dom.test.ts +++ b/src/web/src/app/c/layout.dom.test.ts @@ -120,10 +120,9 @@ describe("CommunityLayout session boundary", () => { process.cwd().endsWith("/src/web") ? "" : "src/web", "src/app/c/me/layout.tsx", ), "utf8") - expect(source).toMatch(/isPending:\s*dmsPending/) + expect(source).not.toContain("useDmRouteVerification") + expect(source).toContain('') expect(source).not.toMatch(/isFetching:\s*dmsFetching/) - expect(source).toContain("const canonicalDmsUnsettled = dmsPending") - expect(source).toContain("useDmRouteVerification(params.dmId, dms, canonicalDmsUnsettled)") }) it("keeps the daemon update controller inside the authenticated Community query cache", () => { diff --git a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts index 9a717521c..1858329b6 100644 --- a/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts +++ b/src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts @@ -1,15 +1,19 @@ import React from "react" import { render } from "@/test/react-dom-harness" import { beforeEach, describe, expect, it, vi } from "vitest" -import DmPage from "./page" +import { DmView } from "@/components/community/channels/dm-view" const { mockDismissConversation, + mockCommitRoute, mockDmMessages, mockDms, mockStore, + mockHistory, } = vi.hoisted(() => ({ mockDismissConversation: vi.fn(), + mockCommitRoute: vi.fn(), + mockHistory: { allowed: true, error: null as Error | null, retry: vi.fn() }, mockDms: { dms: [] as Array<{ id: string @@ -38,12 +42,26 @@ vi.mock("@/components/community/channels/dm-header", () => ({ DmHeader: () => nu vi.mock("@/components/community/channels/dm-loading-frame", () => ({ DmLoadingFrame: () => React.createElement("div", { "data-testid": "dm-loading" }), })) +vi.mock("@/components/community/channels/dm-route-error-frame", () => ({ + DmRouteErrorFrame: () => React.createElement("div", { "data-testid": "dm-error" }), +})) +vi.mock("@/components/community/channels/conversation-resolution-error-frame", () => ({ + ConversationResolutionErrorFrame: ({ onRetry }: { onRetry: () => void }) => React.createElement("button", { onClick: onRetry, "data-testid": "history-error" }, "Retry"), +})) +vi.mock("@/hooks/community/use-channel-metadata", () => ({ + useChannelMetadata: () => ({ isVerified: true, data: { historyVerification: mockHistory.allowed ? {} : undefined } }), +})) +vi.mock("@/hooks/community/channel-metadata", () => ({ isChannelMetadataTokenCurrent: () => true })) +vi.mock("@/lib/community/last-community-route", () => ({ commitCommunityChannelRoute: mockCommitRoute })) vi.mock("@/components/community/avatar", () => ({ Avatar: () => null })) -vi.mock("@/components/community/messages/message-list", () => ({ MessageList: () => null })) +vi.mock("@/components/community/messages/message-list", () => ({ MessageList: () => React.createElement("div", { "data-testid": "history-body" }) })) vi.mock("@/components/community/messages/message-context-sheet", () => ({ MessageContextSheet: () => null, })) -vi.mock("@/components/community/messages/composer", () => ({ Composer: () => null })) +vi.mock("@/components/community/messages/composer", () => ({ + Composer: () => React.createElement("div", { "data-testid": "composer" }), + ComposerSkeleton: () => React.createElement("div", { "data-testid": "composer-pending" }), +})) vi.mock("@/components/community/messages/conversation-footer-shell", () => ({ ConversationFooterShell: ({ children }: { children: React.ReactNode }) => children, ConversationFooterSlotProvider: ({ children }: { children: React.ReactNode }) => children, @@ -84,7 +102,7 @@ vi.mock("@/hooks/community/use-messages", () => ({ }), })) vi.mock("@/hooks/community/use-dm-read-state", () => ({ - useDmReadStateSnapshot: () => ({ snapshot: null, isFetching: false }), + useDmReadStateSnapshot: () => ({ snapshot: null, isFetching: false, error: mockHistory.error, retry: mockHistory.retry, retrying: false }), })) vi.mock("@/lib/community/message-read-projection", () => ({ resolveMessageReadProjection: () => ({ newDividerBefore: undefined, anchorFound: false }), @@ -143,7 +161,11 @@ vi.mock("@/hooks/community/use-native-system-notifications", () => ({ describe("DM notification dismissal readiness", () => { beforeEach(() => { mockDismissConversation.mockClear() + mockCommitRoute.mockClear() mockDmMessages.navigationBlocked = false + mockHistory.allowed = true + mockHistory.error = null + mockHistory.retry.mockClear() mockDms.dms = [] mockDms.isLoading = false }) @@ -164,12 +186,32 @@ describe("DM notification dismissal readiness", () => { mockDms.isLoading = isLoading mockDmMessages.navigationBlocked = navigationBlocked - render(React.createElement(DmPage)) + render(React.createElement(DmView, { dmId: "dm_1" })) expect(mockDismissConversation).toHaveBeenCalledExactlyOnceWith( "viewer_1", { kind: "dm", channelId: "dm_1" }, expectedReady, ) + if (expectedReady) expect(mockCommitRoute).toHaveBeenCalledExactlyOnceWith("viewer_1", null, "dm_1") + else expect(mockCommitRoute).not.toHaveBeenCalled() + }) + + it("withholds history/composer and last while read access fails, then permits the same target after retry", () => { + mockDms.dms = [{ id: "dm_1", userId: "peer_1", name: "Peer", avatar: "P" }] + mockHistory.allowed = false + mockHistory.error = Object.assign(new Error("denied"), { status: 403 }) + const view = render(React.createElement(DmView, { dmId: "dm_1" })) + expect(view.container.querySelector('[data-testid="history-error"]')).not.toBeNull() + expect(view.container.querySelector('[data-testid="history-body"]')).toBeNull() + expect(view.container.querySelector('[data-testid="composer"]')).toBeNull() + expect(mockCommitRoute).not.toHaveBeenCalled() + view.container.querySelector('[data-testid="history-error"]')!.click() + expect(mockHistory.retry).toHaveBeenCalledOnce() + mockHistory.allowed = true + mockHistory.error = null + view.rerender(React.createElement(DmView, { dmId: "dm_1" })) + expect(view.container.querySelector('[data-testid="composer"]')).not.toBeNull() + expect(mockCommitRoute).toHaveBeenCalledExactlyOnceWith("viewer_1", null, "dm_1") }) }) diff --git a/src/web/src/app/c/me/[dmId]/page.test.ts b/src/web/src/app/c/me/[dmId]/page.test.ts index 45570f030..ad4300f4d 100644 --- a/src/web/src/app/c/me/[dmId]/page.test.ts +++ b/src/web/src/app/c/me/[dmId]/page.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest" describe("DM page loading ownership", () => { it("keeps full-frame and message-body ownership separate", () => { - const source = readFileSync(new URL("./page.tsx", import.meta.url), "utf8") + const source = readFileSync(new URL("../../../../components/community/channels/dm-view.tsx", import.meta.url), "utf8") expect(source).toContain("fullFramePending: !hasDm && dmsLoading") - expect(source).toContain("notFound: !hasDm && !dmsLoading") + expect(source).toContain("missingPeer: !hasDm && !dmsLoading") expect(source).toContain("messageBodyLoading: hasDm && messagesLoading") expect(source).not.toContain("currentChannelMatches") expect(source).not.toContain("readSnapshotFetching ||\n messagesLoading") @@ -26,20 +26,20 @@ describe("DM page loading ownership", () => { const messageListProps = source.split("")[0] expect(messageListProps).toContain("typingUsers") expect(source).not.toContain('data-onboarding-name={dm.name} className="shrink-0"') - expect(source).toContain("loading={loadingOwnership.messageBodyLoading}") - expect(source).not.toContain(" { - const source = readFileSync(new URL("./page.tsx", import.meta.url), "utf8") + const source = readFileSync(new URL("../../../../components/community/channels/dm-view.tsx", import.meta.url), "utf8") expect(source).toContain("dms.find((candidate) => candidate.id === dmId) ?? null") expect(source).not.toContain("profilesByUserId.get(raw.userId)") }) it("owns lazy channel-directory state and retry inside the keyed DM view", () => { - const source = readFileSync(new URL("./page.tsx", import.meta.url), "utf8") - expect(source).toContain("return ") + const source = readFileSync(new URL("../../../../components/community/channels/dm-view.tsx", import.meta.url), "utf8") + expect(source).toContain("export function DmView({ dmId }") expect(source).toContain("useChannelRefDirectory(channelRefDirectoryEnabled)") expect(source).toContain("loading: !channelRefDirectoryResolved") expect(source).toContain("failed: channelRefDirectoryError") @@ -50,7 +50,7 @@ describe("DM page loading ownership", () => { }) it("keeps chip toggle and picker add on separate reaction intents", () => { - const source = readFileSync(new URL("./page.tsx", import.meta.url), "utf8") + const source = readFileSync(new URL("../../../../components/community/channels/dm-view.tsx", import.meta.url), "utf8") expect(source).toContain("const toggleReaction = useToggleReactionApi()") expect(source).toContain("const addReaction = useAddReactionApi()") expect(source).toContain("onToggleReaction: (id: string, emoji: string) =>\n toggleReaction(") diff --git a/src/web/src/app/c/me/[dmId]/page.tsx b/src/web/src/app/c/me/[dmId]/page.tsx index b1899b2e3..1e78d20e9 100644 --- a/src/web/src/app/c/me/[dmId]/page.tsx +++ b/src/web/src/app/c/me/[dmId]/page.tsx @@ -1,556 +1,3 @@ -"use client" - -import { useCallback, useEffect, useMemo, useState } from "react" -import { useParams, useSearchParams } from "next/navigation" -import { toast } from "sonner" -import { useBreakpoint } from "@/hooks/use-mobile" -import { DmHeader } from "@/components/community/channels/dm-header" -import { DmLoadingFrame } from "@/components/community/channels/dm-loading-frame" -import { Avatar } from "@/components/community/avatar" -import { MessageList } from "@/components/community/messages/message-list" -import { MessageContextSheet } from "@/components/community/messages/message-context-sheet" -import { Composer, type SendAttachment } from "@/components/community/messages/composer" -import { - ConversationFooterShell, - ConversationFooterSlotProvider, -} from "@/components/community/messages/conversation-footer-shell" -import type { FileAttachment, ImagePreview } from "@/lib/community/models/message" -import type { OpenProfile } from "@/components/community/social/profile-types" -import { - useCommunityStore, - useUiHandlers, - useTypingUsersForScope, - useTypingNamesForScope, -} from "@/stores/community" -import { tid } from "@/lib/community/testids" -import { readCommunityProfile } from "@/lib/community/profile-read" -import { makeUserNameResolver } from "@/lib/community/display-name" -import { useDms } from "@/hooks/community/use-dms" -import { useFriends } from "@/hooks/community/use-friends" -import { useDmMessages } from "@/hooks/community/use-messages" -import { useDmReadStateSnapshot } from "@/hooks/community/use-dm-read-state" -import { resolveMessageReadProjection } from "@/lib/community/message-read-projection" -import { useDmWatermark } from "@/hooks/community/use-dm-watermark" -import { useChannelRefDirectory } from "@/hooks/community/use-channel-ref-directory" -import { toChannelRefCandidate } from "@/lib/community/channel-ref-extension" -import { - useAddReactionApi, - useToggleReactionApi, - useToggleMark, -} from "@/hooks/community/mutations" -import { useDmMessageSender } from "@/hooks/community/use-dm-message-sender" -import { useMessageStreamStore } from "@/stores/community/message-stream" -import { useCurrentUser } from "@/contexts/community/current-user" -import { - communityWsSubscribe, - communityWsUnsubscribe, - communityWsSendTyping, - communityWsEndTyping, -} from "@/hooks/community/use-community-ws" -import { - advanceCommunityOnboarding, - readCommunityOnboardingState, -} from "@/lib/community-onboarding" -import { notifLevelDisplay, type NotifLevel } from "@alook/shared" -import { useNotificationSettings } from "@/hooks/community/use-notification-settings" -import { useSetChannelNotif } from "@/hooks/community/mutations" -import { toastApiError } from "@/lib/api/client" -import { displayReplyContent } from "@/lib/community/reply-content" -import { - useCanonicalProfilesByUserId, - useReadStateProjection, -} from "@/lib/community-db/projections" -import { useNativeSystemNotificationConversationDismissal } from "@/hooks/community/use-native-system-notifications" - -// Thin re-mount wrapper — same reason as the server-side channel view: the -// dynamic segment reuses the same component instance across DM switches, so -// keying by dmId tears down the previous view before the next paints. export default function DmPage() { - const params = useParams<{ dmId: string }>() - return -} - -function resolveDmLoadingOwnership({ - hasDm, - dmsLoading, - messagesLoading, -}: { - hasDm: boolean - dmsLoading: boolean - messagesLoading: boolean -}) { - return { - fullFramePending: !hasDm && dmsLoading, - notFound: !hasDm && !dmsLoading, - messageBodyLoading: hasDm && messagesLoading, - } -} - -function DmView() { - const params = useParams<{ dmId: string }>() - const dmId = params.dmId - const bp = useBreakpoint() - const currentUser = useCurrentUser() - const uiHandlers = useUiHandlers() - const notifications = useNotificationSettings() - const setNotification = useSetChannelNotif() - - // MeLayout owns the canonical cold DMs fetch. This second observer consumes - // that result without treating it as stale on mount; explicit WS/query - // invalidation still refetches the active canonical key. - const dmsQuery = useDms() - const dms = dmsQuery.dms - const dmsLoading = dmsQuery.isLoading - const { friends: rawFriends, blocked } = useFriends() - const profilesByUserId = useCanonicalProfilesByUserId() - // Enrich with presence — the Composer @-picker uses `f.status` to render - // the avatar presence dot; without this enrichment every avatar shows offline. - const friends = useMemo( - () => - rawFriends.map((f) => { - const userId = f.userId ?? f.id - const canonical = profilesByUserId.get(userId) - const profile = canonical - ? readCommunityProfile(canonical, userId) - : { ...f, presence: f.status } - return { - ...f, - name: profile.name, - discriminator: profile.discriminator, - avatar: profile.avatar, - avatarVersion: profile.avatarVersion, - status: profile.presence, - statusEmoji: profile.statusEmoji, - statusText: profile.statusText, - } - }), - [profilesByUserId, rawFriends], - ) - // Frozen-once snapshot of the viewer's DM read pointer — the anchor for - // the "New" divider AND the initial-page mode. Mirrors the channel-view - // wiring so both surfaces open with the same anchor-window UX. - const canonicalReadSnapshot = useReadStateProjection(dmId) - const { snapshot: readSnapshot, isFetching: readSnapshotFetching } = - useDmReadStateSnapshot(dmId, canonicalReadSnapshot) - - // Anchor the initial page on the viewer's read pointer. Pass `undefined` - // (not `null`) while the snapshot resolves — the hook's initialPageParam - // gate treats `undefined` as "not-yet-decided" and stays disabled, so we - // don't fire a newest-mode fetch that would immediately be superseded. - const { - messages: fetchedMessages, - isLoading: messagesLoading, - hasMoreOlder: hasMoreMessages, - hasMoreNewer: hasMoreNewerMessages, - isFetchingOlder: isFetchingOlderMessages, - isFetchingNewer: isFetchingNewerMessages, - fetchOlder: fetchOlderMessages, - fetchNewer: fetchNewerMessages, - jumpToPresent, - presentVersion, - latestSeq, - isPending: messagesPending, - isError: messagesError, - refetch: refetchMessages, - navigationBlocked, - anchorReconciled, - } = useDmMessages(dmId, { - lastReadMessageId: readSnapshotFetching - ? undefined - : (readSnapshot?.lastReadMessageId ?? null), - // Inbox clicks already start the canonical messages/read requests in - // parallel before routing. Keep the mounted DM route anchor-first so - // direct, sidebar, Back, and Forward navigation retain their frozen read - // pointer instead of briefly owning a newest request. - waitForAnchor: true, - reconcileLateAnchor: true, - revalidateOnMount: true, - viewerUserId: currentUser.id, - }) - const messages = fetchedMessages - - // Cross-navigation deep-link: a Marked-tab row for a DM message navigates - // here with `?seq=` and we open the context sheet on that message. Read - // once at mount (frozen), mirroring the channel page's `?msg=` — a - // refresh/back doesn't re-trigger it. The DM view has no in-place scroll - // anchor, so the context sheet (seq → id + surrounding window) is the jump. - const searchParams = useSearchParams() - const [initialSeq] = useState(() => { - const raw = searchParams.get("seq") - const n = raw ? Number(raw) : NaN - return Number.isFinite(n) ? n : null - }) - const [contextSheetSeq, setContextSheetSeq] = useState(initialSeq) - // DM composer has no "current server" — flatten every member server's - // channels into one cross-server candidate list so a `/`-ref can be - // dropped into a DM (see plan community-channel-ref.md §6). - const [channelRefDirectoryEnabled, setChannelRefDirectoryEnabled] = useState(false) - const { - directory: channelRefDirectory, - isResolved: channelRefDirectoryResolved, - isLoading: channelRefDirectoryLoading, - isError: channelRefDirectoryError, - refetch: refetchChannelRefDirectory, - } = useChannelRefDirectory(channelRefDirectoryEnabled) - const channelRefCandidates = useMemo( - () => - channelRefDirectory.flatMap((s) => - s.channels.map((ch) => toChannelRefCandidate(s, ch)), - ), - [channelRefDirectory], - ) - const channelRefCandidateSource = useMemo(() => ({ - loading: !channelRefDirectoryResolved && ( - !channelRefDirectoryEnabled || channelRefDirectoryLoading - ), - failed: channelRefDirectoryError, - }), [ - channelRefDirectoryEnabled, - channelRefDirectoryError, - channelRefDirectoryLoading, - channelRefDirectoryResolved, - ]) - const handleChannelRefIntent = useCallback(() => { - if (!channelRefDirectoryEnabled) { - setChannelRefDirectoryEnabled(true) - return - } - if (channelRefDirectoryError) void refetchChannelRefDirectory() - }, [ - channelRefDirectoryEnabled, - channelRefDirectoryError, - refetchChannelRefDirectory, - ]) - // Anchor of the "New" divider: the first non-self message after - // `lastReadMessageId` inside the currently-loaded window. Mirrors the - // channel-view logic exactly — see channel page for why we skip past - // runs of viewer-authored messages (the server advances the sender's - // own watermark on POST, so anchoring above the viewer's own row would - // never be "unread" from the sender's perspective). - // `anchorFound` and `newDividerBefore` MUST be computed inside the same - // memo — mirrors the channel page's identical fix exactly (see its doc - // comment for the Playwright-verified repro of the mount-vs-Fix-3 race - // this closes: two independently-evaluated expressions reading the same - // inputs aren't guaranteed to agree on every commit). - const { newDividerBefore, anchorFound } = useMemo(() => { - if (!readSnapshot) return { newDividerBefore: undefined, anchorFound: false } - return resolveMessageReadProjection({ - messages, - lastReadMessageId: readSnapshot.lastReadMessageId, - viewerUserId: currentUser.id, - anchorReconciled, - }) - }, [anchorReconciled, messages, readSnapshot, currentUser.id]) - - // Gates ``'s mount-time scroll action until the anchor is - // actually present in the loaded `messages`. - const anchorInCache = anchorFound - - // Scroll root of the message list — needed so `useDmWatermark`'s - // IntersectionObserver measures against the correct viewport rather - // than the page viewport. Set once by `MessageList` via `onScrollRoot`. - const [scrollRootEl, setScrollRootEl] = useState(null) - useDmWatermark({ - dmId, - messages, - scrollRootEl, - snapshotStatus: readSnapshotFetching - ? "pending" - : readSnapshot - ? "ready" - : "error", - feedStatus: messagesPending - ? "pending" - : messagesError - ? "error" - : "ready", - tailAttached: !hasMoreNewerMessages, - confirmedSeq: readSnapshot?.lastReadSeq ?? 0, - catchUp: () => refetchMessages(), - }) - - // `↓ N` unread count. Same math as channel: server truth is - // `latestSeq - viewerLastReadSeq`. Clamp to 0 in case the read pointer - // overshoots (e.g. a race between the read-state snapshot and a fresh - // `latestSeq` fetch). - const unreadCount = useMemo(() => { - const seenSeq = readSnapshot?.lastReadSeq ?? 0 - const diff = latestSeq - seenSeq - return diff > 0 ? diff : 0 - }, [latestSeq, readSnapshot]) - - - const typingUsers = useTypingUsersForScope(`dm:${dmId}`) - const typingNames = useTypingNamesForScope(`dm:${dmId}`) - const { accept: acceptDmMessage, retry: retryDmMessage } = useDmMessageSender() - const toggleReaction = useToggleReactionApi() - const addReaction = useAddReactionApi() - const toggleMark = useToggleMark() - - const goBack = useCallback(() => { uiHandlers.goBackMobile?.() }, [uiHandlers]) - - // Jump to a message by seq within THIS DM — invoked by a same-DM message-ref - // pill via the `jumpToSeq` UI-handler. The DM view has no in-place scroll - // target prop (unlike the channel page), so always open the context sheet, - // which resolves seq→id and shows the message with surrounding context. - const jumpToSeq = useCallback((seq: number) => setContextSheetSeq(seq), []) - useEffect(() => { - useCommunityStore.getState().registerUiHandlers({ jumpToSeq }) - return () => useCommunityStore.getState().registerUiHandlers({ jumpToSeq: undefined }) - }, [jumpToSeq]) - - useEffect(() => { - useCommunityStore.getState().setCurrentChannelId(dmId) - communityWsSubscribe({ dmConversationId: dmId }) - return () => { - useCommunityStore.getState().setCurrentChannelId(null) - communityWsUnsubscribe() - } - }, [dmId]) - - const [replyTo, setReplyTo] = useState<{ id: string; authorName: string; text: string } | null>(null) - - useEffect(() => { - setReplyTo(null) - }, [dmId]) - - const dm = useMemo(() => dms.find((candidate) => candidate.id === dmId) ?? null, [dms, dmId]) - - const openProfile: OpenProfile = (name, e, discriminator, userId) => { - uiHandlers.openProfile?.(name, e, discriminator, userId) - } - - // A DM contains only two participants, and the friends-based resolver - // covers neither reliably: the viewer is never in their own friends list, - // and the counterpart is only there if they're actually a friend. Resolve - // those two ids explicitly (self → current user, counterpart → the DM's - // display name) before delegating to friends, so reactions (whose reactor - // is most often the viewer) never fall through to "Unknown member". Shared - // by both `typingUsers` and the `resolveUserName` prop so the two paths - // can't drift. - const resolveUserName = useMemo(() => { - const fromFriends = makeUserNameResolver(friends) - return (userId: string) => { - if (userId === currentUser.id) return currentUser.name - if (dm && userId === dm.userId) return dm.name - return fromFriends(userId) - } - }, [friends, currentUser.id, currentUser.name, dm]) - - const advanceOnboardingAfterSend = useCallback(() => { - const state = readCommunityOnboardingState() - if (state?.status === "active" && state.stage === "dm" && state.dmId === dmId) { - advanceCommunityOnboarding("dm", "server") - } - }, [dmId]) - - const messageActions = useMemo(() => ({ - onToggleReaction: (id: string, emoji: string) => - toggleReaction({ dmId, messageId: id, emoji, userId: currentUser.id }), - onReact: (id: string, emoji: string) => - addReaction({ dmId, messageId: id, emoji, userId: currentUser.id }), - onReply: (id: string) => { - const m = messages.find((x) => x.id === id) - if (m) { - setReplyTo({ - id: m.id, - authorName: m.authorName ?? "", - text: displayReplyContent(m.content ?? "", m.replyTo), - }) - } - }, - onCopy: (id: string) => { - const m = messages.find((x) => x.id === id) - const content = m ? displayReplyContent(m.content ?? "", m.replyTo) : "" - if (content) { navigator.clipboard?.writeText(content); toast("Copied to clipboard") } - }, - // A DM is a channel (type='dm'), so its id IS the mark route's channelId. - onMark: (id: string) => toggleMark(dmId, id), - onRetry: (id: string) => { - const m = messages.find((x) => x.id === id) - if (!m?.clientNonce) return - void retryDmMessage(dmId, m.clientNonce).then((result) => { - if (result.ok) advanceOnboardingAfterSend() - }) - }, - onDismiss: (id: string) => { - const m = messages.find((x) => x.id === id) - if (!m?.clientNonce) return - useMessageStreamStore.getState().dispatch( - { kind: "dm", id: dmId }, - { type: "dismissFailed", nonce: m.clientNonce }, - ) - }, - onPreviewImage: (image: ImagePreview) => { - uiHandlers.previewImage?.(image) - }, - onPreviewAttachment: (attachment: FileAttachment) => { - uiHandlers.previewAttachment?.(attachment) - }, - }), [toggleReaction, addReaction, toggleMark, dmId, currentUser.id, messages, retryDmMessage, uiHandlers, advanceOnboardingAfterSend]) - - // DM endpoint ignores mentionType. Replies are supported — the backend - // persists replyToId for DMs too. - const acceptDmSend = (markdown: string, attachments?: SendAttachment[]): boolean => { - const receipt = acceptDmMessage({ - dmId, - content: markdown, - replyTo: replyTo ?? undefined, - attachments, - author: { - id: currentUser.id, - name: currentUser.name, - avatar: currentUser.avatar, - }, - }) - if (!receipt.accepted) return false - void receipt.committed.then((result) => { - if (result.ok) advanceOnboardingAfterSend() - }) - communityWsEndTyping({ channelId: dmId }) - setReplyTo(null) - return true - } - - const handleTyping = () => { communityWsSendTyping({ channelId: dmId }) } - - // The DM row owns header/composer identity. Until it is known, keep the - // complete DM frame pending; once known, read-state and message fetching - // are allowed to affect only MessageList below. - const loadingOwnership = resolveDmLoadingOwnership({ - hasDm: !!dm, - dmsLoading, - messagesLoading, - }) - useNativeSystemNotificationConversationDismissal(currentUser.id, { - kind: "dm", - channelId: dmId, - }, !navigationBlocked && !loadingOwnership.fullFramePending && !loadingOwnership.notFound && !!dm) - - if (navigationBlocked) { - return - } - - if (loadingOwnership.fullFramePending) { - return - } - - if (loadingOwnership.notFound || !dm) { - return ( -
- Conversation not found -
- ) - } - - const dmBlocked = blocked.some((b) => (b.userId ?? b.id) === dm.userId) - - return ( - <> - setNotification.mutate({ channelId: dmId, level }, { - onError: (error) => toastApiError(error, "Failed to update notification level"), - })} - /> -
- - { }} - onToggleReaction={dmBlocked ? undefined : messageActions.onToggleReaction} - onReact={dmBlocked ? undefined : messageActions.onReact} - onReply={dmBlocked ? undefined : messageActions.onReply} - onCopy={messageActions.onCopy} - onMark={dmBlocked ? undefined : messageActions.onMark} - onRetry={dmBlocked ? undefined : messageActions.onRetry} - onDismiss={dmBlocked ? undefined : messageActions.onDismiss} - onPreviewImage={messageActions.onPreviewImage} - onPreviewAttachment={messageActions.onPreviewAttachment} - onOpenProfile={openProfile} - resolveUserName={resolveUserName} - onScrollRoot={setScrollRootEl} - viewerUserId={currentUser.id} - // Delay initial scroll until the read-state snapshot resolves AND - // the anchor it names is actually present in `messages` — see - // `anchorInCache`'s doc comment above. - initialScrollReady={!readSnapshotFetching && anchorInCache} - hasMore={hasMoreMessages} - isFetchingOlder={isFetchingOlderMessages} - onLoadOlder={fetchOlderMessages} - hasMoreNewer={hasMoreNewerMessages} - isFetchingNewer={isFetchingNewerMessages} - onLoadNewer={fetchNewerMessages} - onJumpToPresent={jumpToPresent} - presentVersion={presentVersion} - unreadCount={unreadCount} - typingUsers={typingUsers.map((id) => typingNames[id] ?? resolveUserName(id))} - hero={ - <> -
-

{dm.name}

-

This is the beginning of your direct message history with {dm.name}.

- - } - /> - - {dmBlocked ? ( -
- You have blocked this user. Unblock to send messages. -
- ) : ( - setReplyTo(null)} - autoFocus={bp === "desktop"} - draftKey={`dm/${dmId}`} - /> - )} -
-
-
- { if (!v) setContextSheetSeq(null) }} - channelId={dmId} - targetSeq={contextSheetSeq} - type="dm" - onOpenProfile={openProfile} - resolveUserName={resolveUserName} - onReply={dmBlocked ? undefined : (target) => { - setReplyTo(target) - setContextSheetSeq(null) - }} - /> - - ) + return null } diff --git a/src/web/src/app/c/me/layout.route-memory.dom.test.ts b/src/web/src/app/c/me/layout.route-memory.dom.test.ts index 230078529..7a0794233 100644 --- a/src/web/src/app/c/me/layout.route-memory.dom.test.ts +++ b/src/web/src/app/c/me/layout.route-memory.dom.test.ts @@ -73,8 +73,8 @@ vi.mock("@/stores/community", () => { vi.mock("@/hooks/community/use-dms", () => ({ useDms: () => ({ dms: [], isLoading: false, isPending: false }), })) -vi.mock("@/hooks/community/use-dm-route-verification", () => ({ - useDmRouteVerification: () => ({ status: mocks.dmStatus, retry: vi.fn(), retrying: false }), +vi.mock("@/components/community/channels/dm-route", () => ({ + DmRoute: ({ dmId }: { dmId: string }) => createElement("main", { "data-testid": "target-dm", "data-channel-id": dmId }, dmId), })) vi.mock("@/hooks/community/use-friends", () => ({ useFriends: () => ({ blocked: [], pending: mocks.pending }), @@ -95,7 +95,7 @@ vi.mock("@/lib/community/profile-read", () => ({ })) vi.mock("@/lib/community/last-me-location", () => ({ ME_ROOT: "/c/me", - resolveMeLocationStatus: () => mocks.locationStatus, + isRememberableMeLocation: () => mocks.locationStatus === "remember", setLastMeLocation: (...args: unknown[]) => mocks.setLastMeLocation(...args), getLastMeLeaf: () => mocks.dmId, meLeafFromPathname: () => mocks.dmId, @@ -151,29 +151,25 @@ describe("MeLayout route memory", () => { expect(mocks.replace).not.toHaveBeenCalled() }) - it("clears a matching failed cold-entry DM and falls back to Machines", () => { - mocks.pathname = "/c/me/dm-missing" - mocks.dmId = "dm-missing" - mocks.dmStatus = "missing" - mocks.locationStatus = "stale" - mocks.consumeColdEntryFailure.mockReturnValue(true) - renderLayout() - expect(mocks.clearLastMeLocation).toHaveBeenCalledTimes(1) - expect(mocks.cancelPendingNavigation).toHaveBeenCalledTimes(1) - expect(mocks.consumeColdEntryFailure).toHaveBeenCalledWith( - "viewer-1", - "/c/me/dm-missing", - ) - expect(mocks.replace).toHaveBeenCalledWith("/c/me/machines") - }) - - it("keeps the existing Me-root fallback for an ordinary invalid deep link", () => { - mocks.pathname = "/c/me/dm-missing" - mocks.dmId = "dm-missing" - mocks.dmStatus = "missing" - mocks.locationStatus = "stale" - renderLayout() - expect(mocks.replace).toHaveBeenCalledWith("/c/me") + it("mounts each target DM while Next children stay on the neutral root, without saving last in layout", () => { + mocks.pathname = "/c/me/dm-a" + mocks.dmId = "dm-a" + const client = new QueryClient() + const tree = () => createElement(QueryClientProvider, { client }, + createElement(MeLayout, null, createElement("div", { "data-testid": "neutral-leaf" }, "Loading your space"))) + const rendered = render(tree()) + expect(rendered.getByTestId("target-dm").getAttribute("data-channel-id")).toBe("dm-a") + expect(rendered.queryByTestId("neutral-leaf")).toBeNull() + mocks.pathname = "/c/me/dm-b" + mocks.dmId = "dm-b" + rendered.rerender(tree()) + expect(rendered.getByTestId("target-dm").getAttribute("data-channel-id")).toBe("dm-b") + expect(rendered.queryByText("dm-a")).toBeNull() + expect(rendered.queryByTestId("neutral-leaf")).toBeNull() + expect(mocks.setLastMeLocation).not.toHaveBeenCalled() + expect(mocks.commitLastCommunityRoute).not.toHaveBeenCalled() + rendered.unmount() + client.clear() }) it("keeps the Friends shortcut count on the shared terminal projection through failed refreshes", async () => { diff --git a/src/web/src/app/c/me/layout.tsx b/src/web/src/app/c/me/layout.tsx index e24f3ba51..29e6376de 100644 --- a/src/web/src/app/c/me/layout.tsx +++ b/src/web/src/app/c/me/layout.tsx @@ -4,37 +4,25 @@ import { useCallback, useEffect, useMemo, type ReactNode } from "react" import { useParams, usePathname, - useRouter, useSelectedLayoutSegments, } from "next/navigation" import { ShellFrame } from "@/components/community/shell/shell-frame" -import { CommunityPendingFrame } from "@/components/community/shell/community-pending-frame" -import { DmRouteErrorFrame } from "@/components/community/channels/dm-route-error-frame" +import { DmRoute } from "@/components/community/channels/dm-route" import { DmSidebar } from "@/components/community/channels/dm-sidebar" import { useCommunityStore, useCurrentChannelId } from "@/stores/community" import { useDms } from "@/hooks/community/use-dms" -import { useDmRouteVerification } from "@/hooks/community/use-dm-route-verification" import { useFriends, useFriendsPresence } from "@/hooks/community/use-friends" import { useInboxUnreads } from "@/hooks/community/use-inbox" import { useCurrentUser } from "@/contexts/community/current-user" import { - clearLastMeLocation, - getLastMeLeaf, - ME_ROOT, - meLeafFromPathname, - resolveMeLocationStatus, + isRememberableMeLocation, setLastMeLocation, } from "@/lib/community/last-me-location" -import { - COMMUNITY_COLD_ENTRY_FALLBACK, - commitLastCommunityRoute, - consumeCommunityColdEntryFailure, -} from "@/lib/community/last-community-route" +import { commitLastCommunityRoute } from "@/lib/community/last-community-route" // DM-side layout. The DM subtree has no server settings, no channel sidebar, // and no `[serverId]` param — everything is scoped to the current user. export default function MeLayout({ children }: { children: ReactNode }) { - const router = useRouter() const pathname = usePathname() const currentUser = useCurrentUser() const params = useParams<{ dmId?: string }>() @@ -45,16 +33,10 @@ export default function MeLayout({ children }: { children: ReactNode }) { const { dms, isLoading: dmsLoading, - isPending: dmsPending, } = useDms() - const canonicalDmsUnsettled = dmsPending - const dmRouteVerification = useDmRouteVerification(params.dmId, dms, canonicalDmsUnsettled) const { blocked } = useFriends() const friendRequestCount = useInboxUnreads().friendRequests.length const currentChannelId = useCurrentChannelId() - const cancelPendingNavigation = useCallback(() => { - useCommunityStore.getState().uiHandlers.cancelPendingNavigation?.() - }, []) // Clear the active server when entering the DM home. `currentServerId === // null` is the canonical "no server focused" state — no need for a "@me" @@ -71,26 +53,11 @@ export default function MeLayout({ children }: { children: ReactNode }) { const botsActive = pathname === "/c/me/bots" const friendsActive = pathname === "/c/me/friends" - const meLocationStatus = resolveMeLocationStatus({ - pathname, - dmId: params.dmId, - dmRouteStatus: dmRouteVerification.status, - }) - useEffect(() => { - if (meLocationStatus === "remember") { - setLastMeLocation(pathname) - commitLastCommunityRoute(currentUser.id, pathname) - return - } - if (meLocationStatus !== "stale") return - if (getLastMeLeaf() === meLeafFromPathname(pathname)) clearLastMeLocation() - cancelPendingNavigation() - const destination = consumeCommunityColdEntryFailure(currentUser.id, pathname) - ? COMMUNITY_COLD_ENTRY_FALLBACK - : ME_ROOT - router.replace(destination) - }, [cancelPendingNavigation, currentUser.id, meLocationStatus, pathname, router]) + if (params.dmId || !isRememberableMeLocation(pathname)) return + setLastMeLocation(pathname) + commitLastCommunityRoute(currentUser.id, pathname) + }, [currentUser.id, params.dmId, pathname]) // Navigation is intentionally read-neutral. The visible-row observer owns // both optimistic clearing and the durable cursor write. @@ -142,13 +109,8 @@ export default function MeLayout({ children }: { children: ReactNode }) { frameHref={structuralFrameHref} sidebar={sidebar} > - {params.dmId && dmRouteVerification.status === "error" - ? - : params.dmId && meLocationStatus !== "remember" - ? + {params.dmId + ? : children} ) diff --git a/src/web/src/components/community/channels/adaptive-navigation-cutover.contract.test.ts b/src/web/src/components/community/channels/adaptive-navigation-cutover.contract.test.ts index c39f59998..eba3690ed 100644 --- a/src/web/src/components/community/channels/adaptive-navigation-cutover.contract.test.ts +++ b/src/web/src/components/community/channels/adaptive-navigation-cutover.contract.test.ts @@ -39,7 +39,7 @@ describe("adaptive navigation cutover contracts", () => { }) it("autofocuses message composers only after desktop is known", () => { - const dmPage = readSource("../../../app/c/me/[dmId]/page.tsx") + const dmPage = readSource("./dm-view.tsx") const textSurface = readSource("./text-channel-surface.tsx") const threadSurface = readSource("./thread-channel-surface.tsx") diff --git a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts index 32336927f..2ec621abe 100644 --- a/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts +++ b/src/web/src/components/community/channels/channel-route.text-scroll-target.dom.test.ts @@ -155,7 +155,10 @@ vi.mock("@/lib/community/last-channel", () => ({ clearLastChannel: (...args: unknown[]) => mockClearLastChannel(...args), })) vi.mock("@/lib/community/last-community-route", () => ({ - commitLastCommunityRoute: (...args: unknown[]) => mockCommitLastCommunityRoute(...args), + commitCommunityChannelRoute: (accountId: string, serverId: string, channelId: string) => { + mockSetLastChannel(serverId, channelId) + mockCommitLastCommunityRoute(accountId, `/c/channels/${serverId}/${channelId}`) + }, })) vi.mock("@/stores/community", () => { const state = { diff --git a/src/web/src/components/community/channels/channel-route.tsx b/src/web/src/components/community/channels/channel-route.tsx index 7bca8794b..832668a4d 100644 --- a/src/web/src/components/community/channels/channel-route.tsx +++ b/src/web/src/components/community/channels/channel-route.tsx @@ -17,8 +17,8 @@ import { ConversationResolutionPendingFrame } from "@/components/community/chann import { useChannelMemberViewModel } from "@/components/community/members/channel-member-view-model" import type { OpenProfile } from "@/components/community/social/profile-types" import { canManageServer, USE_SERVER_DEFAULT } from "@alook/shared" -import { clearLastChannel, setLastChannel } from "@/lib/community/last-channel" -import { commitLastCommunityRoute } from "@/lib/community/last-community-route" +import { clearLastChannel } from "@/lib/community/last-channel" +import { commitCommunityChannelRoute } from "@/lib/community/last-community-route" import { resolveChannelDisplayName } from "@/lib/community/channel-display-name" import { toChannelRefCandidate } from "@/lib/community/channel-ref-extension" import { @@ -232,8 +232,7 @@ export function ChannelRoute({ serverParam, channelId }: { // may commit the destination for a later cold entry. useEffect(() => { if (!channelHydrated) return - setLastChannel(serverId, channelId) - commitLastCommunityRoute(currentUser.id, channelHref(serverId, channelId)) + commitCommunityChannelRoute(currentUser.id, serverId, channelId) }, [channelHydrated, channelId, currentUser.id, serverId]) const subtype = resolveConversationSubtype({ routeLifecycle: routeModel.routeLifecycle, diff --git a/src/web/src/components/community/channels/conversation-resolution-error-frame.tsx b/src/web/src/components/community/channels/conversation-resolution-error-frame.tsx index 98599b2a7..05d6022ab 100644 --- a/src/web/src/components/community/channels/conversation-resolution-error-frame.tsx +++ b/src/web/src/components/community/channels/conversation-resolution-error-frame.tsx @@ -6,12 +6,14 @@ import { Button } from "@/components/ui/button" export function ConversationResolutionErrorFrame({ retrying, onRetry, + as: Element = "main", }: { retrying: boolean onRetry: () => void + as?: "main" | "div" }) { return ( -
+

Couldn't verify this conversation

@@ -20,6 +22,6 @@ export function ConversationResolutionErrorFrame({ -
+ ) } diff --git a/src/web/src/components/community/channels/dm-route.dom.test.ts b/src/web/src/components/community/channels/dm-route.dom.test.ts new file mode 100644 index 000000000..dfb01d872 --- /dev/null +++ b/src/web/src/components/community/channels/dm-route.dom.test.ts @@ -0,0 +1,77 @@ +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { render, screen, setupUser } from "@/test/react-dom-harness" +import { DmRoute } from "./dm-route" + +const mocks = vi.hoisted(() => ({ + verification: { status: "pending", retrying: false, retry: vi.fn() }, + breakpoint: "desktop", + last: "dm-a", + cold: false, + replace: vi.fn(), clear: vi.fn(), purge: vi.fn(), cancel: vi.fn(), consume: vi.fn(), +})) +vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) })) +vi.mock("@/contexts/community/current-user", () => ({ useCurrentUser: () => ({ id: "viewer" }) })) +vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => mocks.breakpoint })) +vi.mock("@/hooks/community/use-dm-route-verification", () => ({ useDmRouteVerification: () => mocks.verification })) +vi.mock("@/stores/community", () => ({ useCommunityStore: { getState: () => ({ uiHandlers: { cancelPendingNavigation: mocks.cancel } }) } })) +vi.mock("@/lib/community-db/projections", () => ({ useOptionalCommunityDbRegistry: () => ({}) })) +vi.mock("@/lib/community-db/sync", () => ({ purgeCommunityChannel: mocks.purge })) +vi.mock("@/lib/community/last-me-location", () => ({ ME_ROOT: "/c/me", getLastMeLeaf: () => mocks.last, clearLastMeLocation: mocks.clear })) +vi.mock("@/lib/community/last-community-route", () => ({ + COMMUNITY_COLD_ENTRY_FALLBACK: "/c/me/machines", + consumeCommunityColdEntryFailure: (...args: unknown[]) => { mocks.consume(...args); return mocks.cold }, +})) +vi.mock("./dm-view", () => ({ DmView: ({ dmId }: { dmId: string }) => React.createElement("main", { "data-channel-id": dmId }, dmId) })) +vi.mock("./dm-loading-frame", () => ({ DmLoadingFrame: ({ reserveBackSlot }: { reserveBackSlot: boolean }) => React.createElement("div", { "data-testid": "dm-pending", "data-back": String(reserveBackSlot) }) })) +vi.mock("./dm-route-error-frame", () => ({ DmRouteErrorFrame: ({ onRetry, retrying }: { onRetry: () => void; retrying: boolean }) => React.createElement("button", { onClick: onRetry, disabled: retrying }, "Retry") })) + +beforeEach(() => { + mocks.verification.status = "pending" + mocks.verification.retrying = false + mocks.breakpoint = "desktop" + mocks.last = "dm-a" + mocks.cold = false + for (const callback of [mocks.replace, mocks.clear, mocks.purge, mocks.cancel, mocks.consume, mocks.verification.retry]) callback.mockClear() +}) + +describe("DM target main owns identity, retry and fallback", () => { + it("reserves the target frame on mobile and mounts only the qualified explicit ID", () => { + mocks.breakpoint = "mobile" + const route = render(React.createElement(DmRoute, { dmId: "dm-a" })) + expect(screen.getByTestId("dm-pending")).toHaveAttribute("data-back", "true") + expect(route.container.querySelector("main")).toBeNull() + expect(mocks.replace).not.toHaveBeenCalled() + mocks.verification.status = "present" + route.rerender(React.createElement(DmRoute, { dmId: "dm-b" })) + expect(route.container.querySelector("main")).toHaveAttribute("data-channel-id", "dm-b") + expect(route.container.textContent).not.toContain("dm-a") + }) + + it("keeps a transient error at the target and retries its resource without changing memory", async () => { + mocks.verification.status = "error" + render(React.createElement(DmRoute, { dmId: "dm-a" })) + await setupUser().click(screen.getByRole("button", { name: "Retry" })) + expect(mocks.verification.retry).toHaveBeenCalledOnce() + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.clear).not.toHaveBeenCalled() + }) + + it.each([false, true])("purges explicit denial and retires only matching last memory, cold=%s", (cold) => { + mocks.verification.status = "missing" + mocks.cold = cold + render(React.createElement(DmRoute, { dmId: "dm-a" })) + expect(mocks.purge).toHaveBeenCalledExactlyOnceWith({}, "dm-a") + expect(mocks.clear).toHaveBeenCalledOnce() + expect(mocks.cancel).toHaveBeenCalledOnce() + expect(mocks.consume).toHaveBeenCalledExactlyOnceWith("viewer", "/c/me/dm-a") + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith(cold ? "/c/me/machines" : "/c/me") + }) + + it("does not erase another remembered DM when this target is denied", () => { + mocks.verification.status = "missing" + mocks.last = "dm-b" + render(React.createElement(DmRoute, { dmId: "dm-a" })) + expect(mocks.clear).not.toHaveBeenCalled() + }) +}) diff --git a/src/web/src/components/community/channels/dm-route.tsx b/src/web/src/components/community/channels/dm-route.tsx new file mode 100644 index 000000000..667632c6e --- /dev/null +++ b/src/web/src/components/community/channels/dm-route.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { DmView } from "./dm-view" +import { DmRouteErrorFrame } from "./dm-route-error-frame" +import { DmLoadingFrame } from "./dm-loading-frame" +import { useDmRouteVerification } from "@/hooks/community/use-dm-route-verification" +import { useBreakpoint } from "@/hooks/use-mobile" +import { useCurrentUser } from "@/contexts/community/current-user" +import { useCommunityStore } from "@/stores/community" +import { useOptionalCommunityDbRegistry } from "@/lib/community-db/projections" +import { purgeCommunityChannel } from "@/lib/community-db/sync" +import { clearLastMeLocation, getLastMeLeaf, ME_ROOT } from "@/lib/community/last-me-location" +import { + COMMUNITY_COLD_ENTRY_FALLBACK, + consumeCommunityColdEntryFailure, +} from "@/lib/community/last-community-route" + +export function DmRoute({ dmId }: { dmId: string }) { + const router = useRouter() + const currentUser = useCurrentUser() + const breakpoint = useBreakpoint() + const registry = useOptionalCommunityDbRegistry() + const verification = useDmRouteVerification(dmId) + useEffect(() => { + if (verification.status !== "missing") return + if (registry) purgeCommunityChannel(registry, dmId) + if (getLastMeLeaf() === dmId) clearLastMeLocation() + useCommunityStore.getState().uiHandlers.cancelPendingNavigation?.() + const href = `/c/me/${encodeURIComponent(dmId)}` + router.replace(consumeCommunityColdEntryFailure(currentUser.id, href) + ? COMMUNITY_COLD_ENTRY_FALLBACK + : ME_ROOT) + }, [currentUser.id, dmId, registry, router, verification.status]) + + if (verification.status === "error") { + return + } + if (verification.status !== "present") { + return + } + return +} diff --git a/src/web/src/components/community/channels/dm-view.tsx b/src/web/src/components/community/channels/dm-view.tsx new file mode 100644 index 000000000..866e990a8 --- /dev/null +++ b/src/web/src/components/community/channels/dm-view.tsx @@ -0,0 +1,556 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useSearchParams } from "next/navigation" +import { toast } from "sonner" +import { useBreakpoint } from "@/hooks/use-mobile" +import { DmHeader } from "@/components/community/channels/dm-header" +import { DmLoadingFrame } from "@/components/community/channels/dm-loading-frame" +import { DmRouteErrorFrame } from "@/components/community/channels/dm-route-error-frame" +import { Avatar } from "@/components/community/avatar" +import { MessageList } from "@/components/community/messages/message-list" +import { MessageContextSheet } from "@/components/community/messages/message-context-sheet" +import { Composer, ComposerSkeleton, type SendAttachment } from "@/components/community/messages/composer" +import { + ConversationFooterShell, + ConversationFooterSlotProvider, +} from "@/components/community/messages/conversation-footer-shell" +import type { FileAttachment, ImagePreview } from "@/lib/community/models/message" +import type { OpenProfile } from "@/components/community/social/profile-types" +import { + useCommunityStore, + useUiHandlers, + useTypingUsersForScope, + useTypingNamesForScope, +} from "@/stores/community" +import { tid } from "@/lib/community/testids" +import { readCommunityProfile } from "@/lib/community/profile-read" +import { makeUserNameResolver } from "@/lib/community/display-name" +import { useDms } from "@/hooks/community/use-dms" +import { useFriends } from "@/hooks/community/use-friends" +import { useDmMessages } from "@/hooks/community/use-messages" +import { useDmReadStateSnapshot } from "@/hooks/community/use-dm-read-state" +import { resolveMessageReadProjection } from "@/lib/community/message-read-projection" +import { useDmWatermark } from "@/hooks/community/use-dm-watermark" +import { useChannelRefDirectory } from "@/hooks/community/use-channel-ref-directory" +import { toChannelRefCandidate } from "@/lib/community/channel-ref-extension" +import { + useAddReactionApi, + useToggleReactionApi, + useToggleMark, +} from "@/hooks/community/mutations" +import { useDmMessageSender } from "@/hooks/community/use-dm-message-sender" +import { useMessageStreamStore } from "@/stores/community/message-stream" +import { useCurrentUser } from "@/contexts/community/current-user" +import { + communityWsSubscribe, + communityWsUnsubscribe, + communityWsSendTyping, + communityWsEndTyping, +} from "@/hooks/community/use-community-ws" +import { + advanceCommunityOnboarding, + readCommunityOnboardingState, +} from "@/lib/community-onboarding" +import { notifLevelDisplay, type NotifLevel } from "@alook/shared" +import { useNotificationSettings } from "@/hooks/community/use-notification-settings" +import { useSetChannelNotif } from "@/hooks/community/mutations" +import { toastApiError } from "@/lib/api/client" +import { displayReplyContent } from "@/lib/community/reply-content" +import { + useCanonicalProfilesByUserId, + useReadStateProjection, +} from "@/lib/community-db/projections" +import { useNativeSystemNotificationConversationDismissal } from "@/hooks/community/use-native-system-notifications" +import { commitCommunityChannelRoute } from "@/lib/community/last-community-route" +import { useChannelMetadata } from "@/hooks/community/use-channel-metadata" +import { isChannelMetadataTokenCurrent } from "@/hooks/community/channel-metadata" +import { ConversationResolutionErrorFrame } from "./conversation-resolution-error-frame" + +function resolveDmLoadingOwnership({ + hasDm, + dmsLoading, + messagesLoading, +}: { + hasDm: boolean + dmsLoading: boolean + messagesLoading: boolean +}) { + return { + fullFramePending: !hasDm && dmsLoading, + missingPeer: !hasDm && !dmsLoading, + messageBodyLoading: hasDm && messagesLoading, + } +} + +export function DmView({ dmId }: { dmId: string }) { + const bp = useBreakpoint() + const currentUser = useCurrentUser() + const metadata = useChannelMetadata(null, dmId) + const uiHandlers = useUiHandlers() + const notifications = useNotificationSettings() + const setNotification = useSetChannelNotif() + + // MeLayout owns the canonical cold DMs fetch. This second observer consumes + // that result without treating it as stale on mount; explicit WS/query + // invalidation still refetches the active canonical key. + const dmsQuery = useDms() + const dms = dmsQuery.dms + const dmsLoading = dmsQuery.isLoading + const { friends: rawFriends, blocked } = useFriends() + const dm = useMemo(() => dms.find((candidate) => candidate.id === dmId) ?? null, [dms, dmId]) + const dmBlocked = !!dm && blocked.some((b) => (b.userId ?? b.id) === dm.userId) + const historyAllowed = !dmBlocked && metadata.isVerified && !!metadata.data?.historyVerification + && isChannelMetadataTokenCurrent(metadata.data.historyVerification) + const profilesByUserId = useCanonicalProfilesByUserId() + // Enrich with presence — the Composer @-picker uses `f.status` to render + // the avatar presence dot; without this enrichment every avatar shows offline. + const friends = useMemo( + () => + rawFriends.map((f) => { + const userId = f.userId ?? f.id + const canonical = profilesByUserId.get(userId) + const profile = canonical + ? readCommunityProfile(canonical, userId) + : { ...f, presence: f.status } + return { + ...f, + name: profile.name, + discriminator: profile.discriminator, + avatar: profile.avatar, + avatarVersion: profile.avatarVersion, + status: profile.presence, + statusEmoji: profile.statusEmoji, + statusText: profile.statusText, + } + }), + [profilesByUserId, rawFriends], + ) + // Frozen-once snapshot of the viewer's DM read pointer — the anchor for + // the "New" divider AND the initial-page mode. Mirrors the channel-view + // wiring so both surfaces open with the same anchor-window UX. + const canonicalReadSnapshot = useReadStateProjection(dmId) + const { snapshot: readSnapshot, isFetching: readSnapshotFetching, error: readError, retry: retryRead, retrying: retryingRead } = + useDmReadStateSnapshot(dmId, canonicalReadSnapshot) + + // Anchor the initial page on the viewer's read pointer. Pass `undefined` + // (not `null`) while the snapshot resolves — the hook's initialPageParam + // gate treats `undefined` as "not-yet-decided" and stays disabled, so we + // don't fire a newest-mode fetch that would immediately be superseded. + const { + messages: fetchedMessages, + isLoading: messagesLoading, + hasMoreOlder: hasMoreMessages, + hasMoreNewer: hasMoreNewerMessages, + isFetchingOlder: isFetchingOlderMessages, + isFetchingNewer: isFetchingNewerMessages, + fetchOlder: fetchOlderMessages, + fetchNewer: fetchNewerMessages, + jumpToPresent, + presentVersion, + latestSeq, + isPending: messagesPending, + isError: messagesError, + refetch: refetchMessages, + navigationBlocked, + anchorReconciled, + } = useDmMessages(dmId, { + lastReadMessageId: readSnapshotFetching + ? undefined + : (readSnapshot?.lastReadMessageId ?? null), + // Inbox clicks already start the canonical messages/read requests in + // parallel before routing. Keep the mounted DM route anchor-first so + // direct, sidebar, Back, and Forward navigation retain their frozen read + // pointer instead of briefly owning a newest request. + waitForAnchor: true, + reconcileLateAnchor: true, + revalidateOnMount: true, + viewerUserId: currentUser.id, + }) + const messages = useMemo(() => historyAllowed ? fetchedMessages : [], [fetchedMessages, historyAllowed]) + + // Cross-navigation deep-link: a Marked-tab row for a DM message navigates + // here with `?seq=` and we open the context sheet on that message. Read + // once at mount (frozen), mirroring the channel page's `?msg=` — a + // refresh/back doesn't re-trigger it. The DM view has no in-place scroll + // anchor, so the context sheet (seq → id + surrounding window) is the jump. + const searchParams = useSearchParams() + const [initialSeq] = useState(() => { + const raw = searchParams.get("seq") + const n = raw ? Number(raw) : NaN + return Number.isFinite(n) ? n : null + }) + const [contextSheetSeq, setContextSheetSeq] = useState(initialSeq) + // DM composer has no "current server" — flatten every member server's + // channels into one cross-server candidate list so a `/`-ref can be + // dropped into a DM (see plan community-channel-ref.md §6). + const [channelRefDirectoryEnabled, setChannelRefDirectoryEnabled] = useState(false) + const { + directory: channelRefDirectory, + isResolved: channelRefDirectoryResolved, + isLoading: channelRefDirectoryLoading, + isError: channelRefDirectoryError, + refetch: refetchChannelRefDirectory, + } = useChannelRefDirectory(channelRefDirectoryEnabled) + const channelRefCandidates = useMemo( + () => + channelRefDirectory.flatMap((s) => + s.channels.map((ch) => toChannelRefCandidate(s, ch)), + ), + [channelRefDirectory], + ) + const channelRefCandidateSource = useMemo(() => ({ + loading: !channelRefDirectoryResolved && ( + !channelRefDirectoryEnabled || channelRefDirectoryLoading + ), + failed: channelRefDirectoryError, + }), [ + channelRefDirectoryEnabled, + channelRefDirectoryError, + channelRefDirectoryLoading, + channelRefDirectoryResolved, + ]) + const handleChannelRefIntent = useCallback(() => { + if (!channelRefDirectoryEnabled) { + setChannelRefDirectoryEnabled(true) + return + } + if (channelRefDirectoryError) void refetchChannelRefDirectory() + }, [ + channelRefDirectoryEnabled, + channelRefDirectoryError, + refetchChannelRefDirectory, + ]) + // Anchor of the "New" divider: the first non-self message after + // `lastReadMessageId` inside the currently-loaded window. Mirrors the + // channel-view logic exactly — see channel page for why we skip past + // runs of viewer-authored messages (the server advances the sender's + // own watermark on POST, so anchoring above the viewer's own row would + // never be "unread" from the sender's perspective). + // `anchorFound` and `newDividerBefore` MUST be computed inside the same + // memo — mirrors the channel page's identical fix exactly (see its doc + // comment for the Playwright-verified repro of the mount-vs-Fix-3 race + // this closes: two independently-evaluated expressions reading the same + // inputs aren't guaranteed to agree on every commit). + const { newDividerBefore, anchorFound } = useMemo(() => { + if (!readSnapshot) return { newDividerBefore: undefined, anchorFound: false } + return resolveMessageReadProjection({ + messages, + lastReadMessageId: readSnapshot.lastReadMessageId, + viewerUserId: currentUser.id, + anchorReconciled, + }) + }, [anchorReconciled, messages, readSnapshot, currentUser.id]) + + // Gates ``'s mount-time scroll action until the anchor is + // actually present in the loaded `messages`. + const anchorInCache = anchorFound + + // Scroll root of the message list — needed so `useDmWatermark`'s + // IntersectionObserver measures against the correct viewport rather + // than the page viewport. Set once by `MessageList` via `onScrollRoot`. + const [scrollRootEl, setScrollRootEl] = useState(null) + useDmWatermark({ + dmId, + messages, + scrollRootEl, + snapshotStatus: readSnapshotFetching + ? "pending" + : readSnapshot + ? "ready" + : "error", + feedStatus: messagesPending + ? "pending" + : messagesError + ? "error" + : "ready", + tailAttached: !hasMoreNewerMessages, + confirmedSeq: readSnapshot?.lastReadSeq ?? 0, + catchUp: () => refetchMessages(), + }) + + // `↓ N` unread count. Same math as channel: server truth is + // `latestSeq - viewerLastReadSeq`. Clamp to 0 in case the read pointer + // overshoots (e.g. a race between the read-state snapshot and a fresh + // `latestSeq` fetch). + const unreadCount = useMemo(() => { + const seenSeq = readSnapshot?.lastReadSeq ?? 0 + const diff = latestSeq - seenSeq + return diff > 0 ? diff : 0 + }, [latestSeq, readSnapshot]) + + + const typingUsers = useTypingUsersForScope(`dm:${dmId}`) + const typingNames = useTypingNamesForScope(`dm:${dmId}`) + const { accept: acceptDmMessage, retry: retryDmMessage } = useDmMessageSender() + const toggleReaction = useToggleReactionApi() + const addReaction = useAddReactionApi() + const toggleMark = useToggleMark() + + const goBack = useCallback(() => { uiHandlers.goBackMobile?.() }, [uiHandlers]) + + // Jump to a message by seq within THIS DM — invoked by a same-DM message-ref + // pill via the `jumpToSeq` UI-handler. The DM view has no in-place scroll + // target prop (unlike the channel page), so always open the context sheet, + // which resolves seq→id and shows the message with surrounding context. + const jumpToSeq = useCallback((seq: number) => setContextSheetSeq(seq), []) + useEffect(() => { + useCommunityStore.getState().registerUiHandlers({ jumpToSeq }) + return () => useCommunityStore.getState().registerUiHandlers({ jumpToSeq: undefined }) + }, [jumpToSeq]) + + useEffect(() => { + useCommunityStore.getState().setCurrentChannelId(dmId) + communityWsSubscribe({ dmConversationId: dmId }) + return () => { + useCommunityStore.getState().setCurrentChannelId(null) + communityWsUnsubscribe() + } + }, [dmId]) + + const [replyTo, setReplyTo] = useState<{ id: string; authorName: string; text: string } | null>(null) + + useEffect(() => { + setReplyTo(null) + }, [dmId]) + + + const openProfile: OpenProfile = (name, e, discriminator, userId) => { + uiHandlers.openProfile?.(name, e, discriminator, userId) + } + + // A DM contains only two participants, and the friends-based resolver + // covers neither reliably: the viewer is never in their own friends list, + // and the counterpart is only there if they're actually a friend. Resolve + // those two ids explicitly (self → current user, counterpart → the DM's + // display name) before delegating to friends, so reactions (whose reactor + // is most often the viewer) never fall through to "Unknown member". Shared + // by both `typingUsers` and the `resolveUserName` prop so the two paths + // can't drift. + const resolveUserName = useMemo(() => { + const fromFriends = makeUserNameResolver(friends) + return (userId: string) => { + if (userId === currentUser.id) return currentUser.name + if (dm && userId === dm.userId) return dm.name + return fromFriends(userId) + } + }, [friends, currentUser.id, currentUser.name, dm]) + + const advanceOnboardingAfterSend = useCallback(() => { + const state = readCommunityOnboardingState() + if (state?.status === "active" && state.stage === "dm" && state.dmId === dmId) { + advanceCommunityOnboarding("dm", "server") + } + }, [dmId]) + + const messageActions = useMemo(() => ({ + onToggleReaction: (id: string, emoji: string) => + toggleReaction({ dmId, messageId: id, emoji, userId: currentUser.id }), + onReact: (id: string, emoji: string) => + addReaction({ dmId, messageId: id, emoji, userId: currentUser.id }), + onReply: (id: string) => { + const m = messages.find((x) => x.id === id) + if (m) { + setReplyTo({ + id: m.id, + authorName: m.authorName ?? "", + text: displayReplyContent(m.content ?? "", m.replyTo), + }) + } + }, + onCopy: (id: string) => { + const m = messages.find((x) => x.id === id) + const content = m ? displayReplyContent(m.content ?? "", m.replyTo) : "" + if (content) { navigator.clipboard?.writeText(content); toast("Copied to clipboard") } + }, + // A DM is a channel (type='dm'), so its id IS the mark route's channelId. + onMark: (id: string) => toggleMark(dmId, id), + onRetry: (id: string) => { + const m = messages.find((x) => x.id === id) + if (!m?.clientNonce) return + void retryDmMessage(dmId, m.clientNonce).then((result) => { + if (result.ok) advanceOnboardingAfterSend() + }) + }, + onDismiss: (id: string) => { + const m = messages.find((x) => x.id === id) + if (!m?.clientNonce) return + useMessageStreamStore.getState().dispatch( + { kind: "dm", id: dmId }, + { type: "dismissFailed", nonce: m.clientNonce }, + ) + }, + onPreviewImage: (image: ImagePreview) => { + uiHandlers.previewImage?.(image) + }, + onPreviewAttachment: (attachment: FileAttachment) => { + uiHandlers.previewAttachment?.(attachment) + }, + }), [toggleReaction, addReaction, toggleMark, dmId, currentUser.id, messages, retryDmMessage, uiHandlers, advanceOnboardingAfterSend]) + + // DM endpoint ignores mentionType. Replies are supported — the backend + // persists replyToId for DMs too. + const acceptDmSend = (markdown: string, attachments?: SendAttachment[]): boolean => { + const receipt = acceptDmMessage({ + dmId, + content: markdown, + replyTo: replyTo ?? undefined, + attachments, + author: { + id: currentUser.id, + name: currentUser.name, + avatar: currentUser.avatar, + }, + }) + if (!receipt.accepted) return false + void receipt.committed.then((result) => { + if (result.ok) advanceOnboardingAfterSend() + }) + communityWsEndTyping({ channelId: dmId }) + setReplyTo(null) + return true + } + + const handleTyping = () => { communityWsSendTyping({ channelId: dmId }) } + + // The DM row owns header/composer identity. Until it is known, keep the + // complete DM frame pending; once known, read-state and message fetching + // are allowed to affect only MessageList below. + const loadingOwnership = resolveDmLoadingOwnership({ + hasDm: !!dm, + dmsLoading, + messagesLoading, + }) + const routeReady = historyAllowed && !navigationBlocked && !loadingOwnership.fullFramePending + && !loadingOwnership.missingPeer && !!dm + useEffect(() => { + if (!routeReady) return + commitCommunityChannelRoute(currentUser.id, null, dmId) + }, [currentUser.id, dmId, routeReady]) + useNativeSystemNotificationConversationDismissal(currentUser.id, { + kind: "dm", + channelId: dmId, + }, routeReady) + + if (navigationBlocked) { + return + } + + if (loadingOwnership.fullFramePending) { + return + } + + if (loadingOwnership.missingPeer || !dm) { + return { void dmsQuery.refetch({ cancelRefetch: false }) }} + retrying={dmsQuery.isFetching} reserveBackSlot={bp === "mobile"} /> + } + + return ( + <> + setNotification.mutate({ channelId: dmId, level }, { + onError: (error) => toastApiError(error, "Failed to update notification level"), + })} + /> +
+ + {!historyAllowed && readError ? : { }} + onToggleReaction={dmBlocked ? undefined : messageActions.onToggleReaction} + onReact={dmBlocked ? undefined : messageActions.onReact} + onReply={dmBlocked ? undefined : messageActions.onReply} + onCopy={messageActions.onCopy} + onMark={dmBlocked ? undefined : messageActions.onMark} + onRetry={dmBlocked ? undefined : messageActions.onRetry} + onDismiss={dmBlocked ? undefined : messageActions.onDismiss} + onPreviewImage={messageActions.onPreviewImage} + onPreviewAttachment={messageActions.onPreviewAttachment} + onOpenProfile={openProfile} + resolveUserName={resolveUserName} + onScrollRoot={setScrollRootEl} + viewerUserId={currentUser.id} + // Delay initial scroll until the read-state snapshot resolves AND + // the anchor it names is actually present in `messages` — see + // `anchorInCache`'s doc comment above. + initialScrollReady={!readSnapshotFetching && anchorInCache} + hasMore={hasMoreMessages} + isFetchingOlder={isFetchingOlderMessages} + onLoadOlder={fetchOlderMessages} + hasMoreNewer={hasMoreNewerMessages} + isFetchingNewer={isFetchingNewerMessages} + onLoadNewer={fetchNewerMessages} + onJumpToPresent={jumpToPresent} + presentVersion={presentVersion} + unreadCount={unreadCount} + typingUsers={typingUsers.map((id) => typingNames[id] ?? resolveUserName(id))} + hero={ + <> +
+

{dm.name}

+

This is the beginning of your direct message history with {dm.name}.

+ + } + />} + + {dmBlocked ? ( +
+ You have blocked this user. Unblock to send messages. +
+ ) : !historyAllowed ? : ( + setReplyTo(null)} + autoFocus={bp === "desktop"} + draftKey={`dm/${dmId}`} + /> + )} +
+
+
+ {historyAllowed && { if (!v) setContextSheetSeq(null) }} + channelId={dmId} + targetSeq={contextSheetSeq} + type="dm" + onOpenProfile={openProfile} + resolveUserName={resolveUserName} + onReply={dmBlocked ? undefined : (target) => { + setReplyTo(target) + setContextSheetSeq(null) + }} + />} + + ) +} diff --git a/src/web/src/components/community/messages/attachment-download-surfaces.contract.test.ts b/src/web/src/components/community/messages/attachment-download-surfaces.contract.test.ts index 85e8f7d66..048a8b9dd 100644 --- a/src/web/src/components/community/messages/attachment-download-surfaces.contract.test.ts +++ b/src/web/src/components/community/messages/attachment-download-surfaces.contract.test.ts @@ -19,7 +19,7 @@ describe("Community attachment download surface contract", () => { it("does not restore a direct anchor fallback in DM, channel, thread, context, or preview", () => { for (const path of [ - "src/app/c/me/[dmId]/page.tsx", + "src/components/community/channels/dm-view.tsx", "src/components/community/channels/thread-channel-surface.tsx", "src/components/community/messages/message-channel-controller-actions.ts", "src/components/community/messages/message-context-sheet.tsx", diff --git a/src/web/src/components/community/messages/composer-facade.contract.test.ts b/src/web/src/components/community/messages/composer-facade.contract.test.ts index f640be409..8fc1a796a 100644 --- a/src/web/src/components/community/messages/composer-facade.contract.test.ts +++ b/src/web/src/components/community/messages/composer-facade.contract.test.ts @@ -32,7 +32,7 @@ describe("Composer public facade", () => { }) it("keeps DM, channel, and thread on the structured reply-target contract", () => { - const dm = readWeb("src/app/c/me/[dmId]/page.tsx") + const dm = readWeb("src/components/community/channels/dm-view.tsx") const channel = readWeb("src/components/community/channels/text-channel-surface.tsx") const thread = readWeb("src/components/community/channels/thread-channel-surface.tsx") @@ -78,7 +78,7 @@ describe("Composer public facade", () => { it("keeps every direct importer on the facade and out of internals", () => { const importers = [ - "src/app/c/me/[dmId]/page.tsx", + "src/components/community/channels/dm-view.tsx", "src/components/community/channels/thread-channel-surface.tsx", "src/components/community/channels/channel-route.tsx", "src/components/community/channels/text-channel-surface.tsx", diff --git a/src/web/src/components/community/messages/message-link-surfaces.contract.test.ts b/src/web/src/components/community/messages/message-link-surfaces.contract.test.ts index e136e535a..f2a4f5a43 100644 --- a/src/web/src/components/community/messages/message-link-surfaces.contract.test.ts +++ b/src/web/src/components/community/messages/message-link-surfaces.contract.test.ts @@ -9,7 +9,7 @@ const source = (path: string) => readFileSync(resolve(webRoot, path), "utf8") describe("Community ordinary-link action surface contract", () => { it("routes DM, channel, and thread timeline messages through one Message boundary", () => { for (const path of [ - "src/app/c/me/[dmId]/page.tsx", + "src/components/community/channels/dm-view.tsx", "src/components/community/channels/text-channel-surface.tsx", "src/components/community/channels/thread-channel-surface.tsx", ]) { @@ -30,7 +30,7 @@ describe("Community ordinary-link action surface contract", () => { it("keeps gesture target parsing and link-menu labels out of route-specific surfaces", () => { for (const path of [ - "src/app/c/me/[dmId]/page.tsx", + "src/components/community/channels/dm-view.tsx", "src/components/community/channels/text-channel-surface.tsx", "src/components/community/channels/thread-channel-surface.tsx", "src/components/community/messages/message-context-sheet.tsx", diff --git a/src/web/src/components/community/messages/message-list-facade.contract.test.ts b/src/web/src/components/community/messages/message-list-facade.contract.test.ts index afe8c93ee..7fc3f49d1 100644 --- a/src/web/src/components/community/messages/message-list-facade.contract.test.ts +++ b/src/web/src/components/community/messages/message-list-facade.contract.test.ts @@ -78,7 +78,7 @@ describe("MessageList facade contract", () => { it("keeps all production consumers on the original path", () => { const importers = [ - "src/app/c/me/[dmId]/page.tsx", + "src/components/community/channels/dm-view.tsx", "src/components/community/channels/thread-channel-surface.tsx", "src/components/community/channels/text-channel-surface.tsx", ] diff --git a/src/web/src/components/community/shell/use-shell-rail-controller.dom.test.ts b/src/web/src/components/community/shell/use-shell-rail-controller.dom.test.ts index 19d3c8e86..7ba20c9dc 100644 --- a/src/web/src/components/community/shell/use-shell-rail-controller.dom.test.ts +++ b/src/web/src/components/community/shell/use-shell-rail-controller.dom.test.ts @@ -47,10 +47,9 @@ vi.mock("@/stores/community", () => ({ useCommunityStore: (selector: (state: { currentServerId: string }) => unknown) => selector({ currentServerId: "s1" }), })) -vi.mock("@/lib/community/last-channel", () => ({ +vi.mock("@/lib/community/last-channel", async (importOriginal) => ({ + ...await importOriginal(), getLastChannel: () => mocks.lastChannel.current, - pickServerLandingHref: (id: string, channelIds: string[]) => - channelIds[0] ? `/c/channels/${id}/${channelIds[0]}` : `/c/channels/${id}`, })) vi.mock("@/lib/community/last-me-location", () => ({ ME_ROOT: "/c/me", @@ -241,7 +240,7 @@ describe("useShellRailController", () => { expect(hook.queryClient.fetchQuery).not.toHaveBeenCalled() }) - it("always sends rail selection to the semantic server root", async () => { + it("restores the remembered server target directly on desktop", async () => { const hook = await renderController() hook.cache.set("s1", { categories: [{ channels: [{ id: "cached", pending: false }] }], @@ -249,7 +248,7 @@ describe("useShellRailController", () => { mocks.lastChannel.current = "cached" await act(async () => hook.current.railProps.onServerNavigate("s1")) - expect(hook.pushed).toEqual(["/c/channels/s1"]) + expect(hook.pushed).toEqual(["/c/channels/s1/cached"]) }) it("uses canonical channels only when the restored server detail is complete", async () => { @@ -279,7 +278,7 @@ describe("useShellRailController", () => { expect(hook.prefetched).toEqual([]) }) - it("keeps rail navigation on the semantic server root without prefetch", async () => { + it("uses the same remembered-target resolution without prefetch", async () => { const hook = await renderController() hook.cache.set("s1", { categories: [{ channels: [{ id: "pending", pending: true }, { id: "cached", pending: false }] }], @@ -287,12 +286,12 @@ describe("useShellRailController", () => { mocks.lastChannel.current = "cached" await act(async () => hook.current.railProps.onServerNavigate("s1")) - expect(hook.pushed).toEqual(["/c/channels/s1"]) + expect(hook.pushed).toEqual(["/c/channels/s1/cached"]) expect(hook.prefetched).toEqual([]) expect(hook.queryClient.fetchQuery).not.toHaveBeenCalled() await act(async () => hook.current.railProps.onServerNavigate("s2")) - expect(hook.pushed).toContain("/c/channels/s2") + expect(hook.pushed).toContain("/c/channels/s2/cached") expect(hook.prefetched).toEqual([]) expect(hook.current.railProps).not.toHaveProperty("onServerPrefetch") expect(hook.current.railProps).not.toHaveProperty("onHomePrefetch") diff --git a/src/web/src/components/community/shell/use-shell-rail-controller.ts b/src/web/src/components/community/shell/use-shell-rail-controller.ts index 45d81272a..22df680b8 100644 --- a/src/web/src/components/community/shell/use-shell-rail-controller.ts +++ b/src/web/src/components/community/shell/use-shell-rail-controller.ts @@ -13,8 +13,8 @@ import { useLeaveServer, useUploadServerIcon, } from "@/hooks/community/mutations" -import { getLastChannel, pickServerLandingHref } from "@/lib/community/last-channel" -import { getLastMeLeaf, ME_ROOT, pickMeLandingLocation } from "@/lib/community/last-me-location" +import { getLastChannel, resolveCommunityLandingHref } from "@/lib/community/last-channel" +import { getLastMeLeaf } from "@/lib/community/last-me-location" import type { Breakpoint } from "@/hooks/use-mobile" import { useCommunityStore } from "@/stores/community" import { resolveServerRailOverlayAction } from "./server-rail-actions" @@ -80,16 +80,14 @@ export function useShellRailController({ .map((channel) => channel.id) : undefined) ?? [] - return pickServerLandingHref(id, channelIds, getLastChannel(id)) - }, [communityDb, queryClient]) + return resolveCommunityLandingHref({ serverId: id, channelIds, last: getLastChannel(id), breakpoint }) + }, [breakpoint, communityDb, queryClient]) const onServerNavigate = useCallback((id: string) => { markSwitch("server", id) - navigation.push(`/c/channels/${id}`) - }, [navigation]) + navigation.push(serverDestination(id)) + }, [navigation, serverDestination]) const homeDestination = useCallback( - () => breakpoint === "desktop" - ? pickMeLandingLocation(getLastMeLeaf()) - : ME_ROOT, + () => resolveCommunityLandingHref({ serverId: null, last: getLastMeLeaf(), breakpoint }), [breakpoint], ) const onHome = useCallback(() => { diff --git a/src/web/src/hooks/community/channel-metadata.test.ts b/src/web/src/hooks/community/channel-metadata.test.ts index e786a15eb..f2e4dc17c 100644 --- a/src/web/src/hooks/community/channel-metadata.test.ts +++ b/src/web/src/hooks/community/channel-metadata.test.ts @@ -20,6 +20,12 @@ beforeEach(() => { }) describe("canonical channel metadata lifecycle", () => { + it.each(["text", "forum", "thread", "dm"])("validates the exact %s resource with its nullable scope", async (type) => { + const serverId = type === "dm" ? null : "server" + fetchMock.mockResolvedValue({ ...metadata, serverId, type, name: type === "dm" ? null : metadata.name }) + await expect(fetchChannelMetadata(serverId, "child")).resolves.toMatchObject({ id: "child", serverId, type, name: type === "dm" ? "" : metadata.name }) + expect(useCommunityWsStore.getState().channelAccessScopes.has("child")).toBe(type !== "dm") + }) it.each(["account", "parent", "server", "membership"] as const)( "rejects an old successful HTTP response after %s changes", async (change) => { diff --git a/src/web/src/hooks/community/channel-metadata.ts b/src/web/src/hooks/community/channel-metadata.ts index 8ac7f11b1..c0101e831 100644 --- a/src/web/src/hooks/community/channel-metadata.ts +++ b/src/web/src/hooks/community/channel-metadata.ts @@ -1,10 +1,20 @@ import { apiFetch } from "@/lib/api/client" import { useCommunityWsStore } from "@/stores/community/ws" +import type { QueryClient } from "@tanstack/react-query" +import { queryOptions } from "@tanstack/react-query" +import { communityKeys } from "@/lib/query-keys" +import { ApiError } from "@/lib/errors" +import { + assertCommunityLiveSnapshotTokenCurrent, + captureCommunityLiveSnapshotToken, + publishCommunityChannelMetadata, +} from "@/lib/community-db/sync" export type ChannelMetadata = { + historyVerification?: ReturnType id: string - serverId: string - name: string + serverId: string | null + name: string | null type: string parentChannelId: string | null parentMessageId: string | null @@ -34,16 +44,57 @@ export function isChannelMetadataTokenCurrent(token: ReturnType void, ) { const token = captureChannelMetadataToken(channelId) - const meta = await apiFetch(`/api/community/channels/${channelId}`, { signal }) + const meta = await apiFetch(`/api/community/channels/${encodeURIComponent(channelId)}`, { signal }) if (!isChannelMetadataTokenCurrent(token) || signal?.aborted) throw new DOMException("Stale channel metadata", "AbortError") - if (meta.id !== channelId || meta.serverId !== serverId || !["text", "forum", "thread"].includes(meta.type)) throw new Error("Channel metadata scope mismatch") - useCommunityWsStore.getState().grantServerAccess(serverId) - useCommunityWsStore.getState().rememberChannelAccess(serverId, channelId, meta.parentChannelId) - return { ...meta, archived: meta.archived === true || meta.archived === 1, - activityAt: meta.lastMessageAt ?? meta.createdAt, verifiedEpoch: token.accessEpoch } + validatePublication?.() + if (meta.id !== channelId || meta.serverId !== serverId + || !(serverId === null ? meta.type === "dm" : ["text", "forum", "thread"].includes(meta.type)) + || !(typeof meta.name === "string" || (serverId === null && meta.name === null))) { + throw new Error("Channel metadata scope mismatch") + } + if (serverId !== null) { + useCommunityWsStore.getState().grantServerAccess(serverId) + useCommunityWsStore.getState().rememberChannelAccess(serverId, channelId, meta.parentChannelId) + } + return { ...meta, name: meta.name ?? "", archived: meta.archived === true || meta.archived === 1, + activityAt: meta.lastMessageAt ?? meta.createdAt, verifiedEpoch: token.accessEpoch, verification: token } +} + +async function fetchAndPublishChannelMetadata( + queryClient: QueryClient, + serverId: string | null, + channelId: string, + signal?: AbortSignal, +) { + const token = captureCommunityLiveSnapshotToken(queryClient) + const validate = () => assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) + try { + const metadata = await fetchChannelMetadata(serverId, channelId, signal, validate) + publishCommunityChannelMetadata(queryClient, { metadata, proof: { token, signal } }) + return metadata + } catch (error) { + validate() + throw error + } +} + +export function channelMetadataOptions(queryClient: QueryClient, serverId: string | null, channelId: string) { + return queryOptions({ + queryKey: communityKeys.channelMeta(serverId, channelId), + queryFn: async ({ signal }) => { + const metadata = await fetchAndPublishChannelMetadata(queryClient, serverId, channelId, signal) + const previous = queryClient.getQueryData(communityKeys.channelMeta(serverId, channelId)) + return { ...metadata, historyVerification: previous?.historyVerification } + }, + staleTime: Infinity, + gcTime: 5 * 60 * 1000, + retry: (failureCount, error) => + !(error instanceof ApiError && [401, 403, 404].includes(error.status)) && failureCount < 1, + }) } diff --git a/src/web/src/hooks/community/use-channel-metadata.dom.test.ts b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts new file mode 100644 index 000000000..65196a111 --- /dev/null +++ b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts @@ -0,0 +1,150 @@ +import React from "react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { act, renderHook, waitFor } from "@/test/react-dom-harness" +import { communityKeys } from "@/lib/query-keys" +import { useCommunityWsStore } from "@/stores/community/ws" +import { createCommunityDbRegistry, registerCommunityDbRegistry, type CommunityDbRegistry } from "@/lib/community-db/collections" +import { CommunityDbProvider, useDmProjection } from "@/lib/community-db/projections" +import { captureCommunityLiveSnapshotToken, ingestDms, publishCommunityLiveSnapshot } from "@/lib/community-db/sync" +import { channelMetadataOptions, captureChannelMetadataToken, isChannelMetadataTokenCurrent } from "./channel-metadata" +import { useChannelMetadata } from "./use-channel-metadata" +import { useDmRouteVerification } from "./use-dm-route-verification" +import { useDmReadStateSnapshot } from "./use-dm-read-state" + +const apiFetch = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })) +const metadata = { id: "dm-a", serverId: null, type: "dm", name: null, + parentChannelId: null, parentMessageId: null, creatorId: null, archived: false, + lastMessageAt: null, createdAt: "2026-10-02T00:00:00Z" } +const dms = { conversations: [{ id: metadata.id, userId: "peer", name: "Peer", discriminator: "0001", + avatar: "P", status: "offline" as const, preview: "canonical preview" }] } +const readState = { lastReadMessageId: null, lastReadAt: null, lastReadSeq: 0 } +const metadataKey = communityKeys.channelMeta(null, metadata.id) +const registries: CommunityDbRegistry[] = [] +const unregisters: Array<() => void> = [] +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } +} +async function fixture(client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })) { + const registry = createCommunityDbRegistry(client, "viewer") + registries.push(registry) + await registry.preload() + unregisters.push(registerCommunityDbRegistry(registry)) + const wrapper = ({ children }: React.PropsWithChildren) => React.createElement(QueryClientProvider, { client }, + React.createElement(CommunityDbProvider, { registry }, children)) + return { client, registry, wrapper } +} +function publishDms(registry: CommunityDbRegistry) { + publishCommunityLiveSnapshot(registry.queryClient, { snapshot: { kind: "dms", data: dms }, + proof: { kind: "structural", token: captureCommunityLiveSnapshotToken(registry.queryClient), signal: undefined } }) +} +beforeEach(() => { + apiFetch.mockReset() + useCommunityWsStore.getState().reset() + useCommunityWsStore.getState().activateProfileAccount("viewer") +}) +afterEach(async () => { + unregisters.splice(0).forEach((unregister) => unregister()) + for (const registry of registries.splice(0)) { await registry["cleanup"](); registry.queryClient.clear() } +}) + +describe("shared Channel resource and canonical DM publication", () => { + it("publishes a real nullable DM response without losing peer closure or granting a server scope", async () => { + const { client, registry, wrapper } = await fixture() + ingestDms(registry, dms) + apiFetch.mockResolvedValue(metadata) + await client.fetchQuery(channelMetadataOptions(client, null, metadata.id)) + const route = renderHook(() => ({ route: useDmRouteVerification(metadata.id), peers: useDmProjection() }), { wrapper }) + await waitFor(() => expect(route.result.current.route.status).toBe("present")) + await waitFor(() => expect(route.result.current.peers?.[0]).toMatchObject({ id: metadata.id, name: "Peer", preview: "canonical preview" })) + expect(registry.collections.channels.get(metadata.id)).toMatchObject({ name: "", serverId: null, type: "dm" }) + expect(useCommunityWsStore.getState().channelAccessScopes.size).toBe(0) + expect(useCommunityWsStore.getState().revokedServerIds.size).toBe(0) + expect(apiFetch).toHaveBeenCalledOnce() + route.unmount() + }) + + it("reuses current live canonical qualification through the same Channel Query without another GET", async () => { + const { registry, wrapper } = await fixture() + publishDms(registry) + const route = renderHook(() => useChannelMetadata(null, metadata.id), { wrapper }) + expect(route.result.current.isVerified).toBe(true) + expect(route.result.current.data?.historyVerification).toBeUndefined() + expect(apiFetch).not.toHaveBeenCalled() + route.unmount() + }) + + it("keeps restored DM structure pending until current target metadata qualifies it", async () => { + const donor = await fixture() + ingestDms(donor.registry, dms) + const restored = new QueryClient() + for (const name of ["channels", "channelMemberships", "profiles"] as const) { + const key = communityKeys.communityDbCollection("viewer", name) + restored.setQueryData(key, donor.client.getQueryData(key)) + } + const { wrapper } = await fixture(restored) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => useDmRouteVerification(metadata.id), { wrapper }) + expect(route.result.current.status).toBe("pending") + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + await act(async () => request.resolve(metadata)) + await waitFor(() => expect(route.result.current.status).toBe("present")) + route.unmount() + }) +}) + +describe("DM history permission stays separate from metadata identity", () => { + it("records only a current read permission receipt on the existing Channel resource", async () => { + const { client, registry, wrapper } = await fixture() + publishDms(registry) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => ({ metadata: useChannelMetadata(null, metadata.id), read: useDmReadStateSnapshot(metadata.id) }), { wrapper }) + expect(route.result.current.metadata.isVerified).toBe(true) + expect(route.result.current.metadata.data?.historyVerification).toBeUndefined() + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + await act(async () => request.resolve(readState)) + await waitFor(() => expect(route.result.current.metadata.data?.historyVerification).toBeDefined()) + expect(isChannelMetadataTokenCurrent(route.result.current.metadata.data!.historyVerification!)).toBe(true) + expect(apiFetch).toHaveBeenCalledWith(`/api/community/channels/${metadata.id}/read-state`, { signal: expect.any(AbortSignal) }) + expect(client.getQueryData(metadataKey)).toMatchObject({ type: "dm" }) + route.unmount() + }) + + it("removes a prior history receipt after an explicit read denial without retrying it", async () => { + const { client, registry, wrapper } = await fixture() + publishDms(registry) + client.setQueryData(metadataKey, (previous: object | undefined) => ({ ...previous, historyVerification: captureChannelMetadataToken(metadata.id) })) + apiFetch.mockRejectedValue(Object.assign(new Error("blocked"), { status: 403 })) + const route = renderHook(() => ({ metadata: useChannelMetadata(null, metadata.id), read: useDmReadStateSnapshot(metadata.id) }), { wrapper }) + await waitFor(() => expect(route.result.current.read.error).toMatchObject({ status: 403 })) + expect(route.result.current.metadata.isVerified).toBe(true) + expect(route.result.current.metadata.data?.historyVerification).toBeUndefined() + expect(apiFetch).toHaveBeenCalledOnce() + route.unmount() + }) + + it.each(["account", "replacement"] as const)("cannot grant history from an old read completion after %s", async (race) => { + const { client, registry, wrapper } = await fixture() + publishDms(registry) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => useDmReadStateSnapshot(metadata.id), { wrapper }) + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + if (race === "account") { + act(() => { useCommunityWsStore.getState().activateProfileAccount("other"); useCommunityWsStore.getState().activateProfileAccount("viewer") }) + } else { + client.removeQueries({ queryKey: metadataKey, exact: true }) + publishDms(registry) + } + await act(async () => request.resolve(readState)) + if (race === "account") await waitFor(() => expect(route.result.current.error?.name).toBe("AbortError")) + else await waitFor(() => expect(route.result.current.snapshot).toEqual(readState)) + expect(client.getQueryData(metadataKey)).not.toHaveProperty("historyVerification") + route.unmount() + }) +}) diff --git a/src/web/src/hooks/community/use-channel-metadata.ts b/src/web/src/hooks/community/use-channel-metadata.ts new file mode 100644 index 000000000..cec59ab13 --- /dev/null +++ b/src/web/src/hooks/community/use-channel-metadata.ts @@ -0,0 +1,49 @@ +"use client" + +import { useEffect, useMemo } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { channelMetadataOptions, isChannelMetadataTokenCurrent } from "./channel-metadata" +import { useRouteChannelProjection } from "@/lib/community-db/projections" +import { useCommunityWsStore } from "@/stores/community/ws" +import { communityKeys } from "@/lib/query-keys" + +export function useChannelMetadata(serverId: string | null, channelId: string | undefined) { + const queryClient = useQueryClient() + const accessEpoch = useCommunityWsStore((state) => state.accessEpoch) + const accountEpoch = useCommunityWsStore((state) => state.profileAccountEpoch) + const viewerId = useCommunityWsStore((state) => state.profileViewerId) + const generation = useCommunityWsStore((state) => channelId + ? state.channelAccessScopes.get(channelId)?.generation ?? 0 : 0) + const revoked = useCommunityWsStore((state) => channelId + ? state.isChannelAccessRevoked(channelId, serverId ?? undefined) + : false) + const canonical = useRouteChannelProjection(channelId ?? null) + const options = channelMetadataOptions(queryClient, serverId, channelId ?? "__none__") + const query = useQuery({ + ...options, + enabled: !!channelId, + retry: serverId === null ? false : options.retry, + retryOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + useEffect(() => { + if (!channelId || query.data === undefined + || (query.data.verification && isChannelMetadataTokenCurrent(query.data.verification))) return + void queryClient.invalidateQueries({ queryKey: communityKeys.channelMeta(serverId, channelId), exact: true }) + }, [accessEpoch, accountEpoch, viewerId, generation, channelId, query.data, queryClient, serverId]) + const denied = typeof query.error === "object" && query.error !== null && "status" in query.error + && (query.error.status === 403 || query.error.status === 404) + const isVerified = !!query.data && query.data.id === channelId + && query.data.serverId === serverId && query.data.verifiedEpoch === accessEpoch + && !!query.data.verification && isChannelMetadataTokenCurrent(query.data.verification) + && !revoked && !denied && !query.data.archived && !canonical?.archived && !canonical?.pending + const data = useMemo(() => isVerified && canonical?.id === channelId && canonical.serverId === serverId + ? { ...query.data!, ...canonical, + parentChannelId: canonical.parentChannelId ?? null, + parentMessageId: canonical.parentMessageId ?? null, + creatorId: canonical.creatorId ?? null, + lastMessageAt: canonical.lastMessageAt ?? null } + : query.data, [canonical, channelId, isVerified, query.data, serverId]) + return { ...query, data, isVerified, denied, canonical } +} diff --git a/src/web/src/hooks/community/use-channel-route-model.subscription.dom.test.ts b/src/web/src/hooks/community/use-channel-route-model.subscription.dom.test.ts index 5ba4e2c5f..b8a58ac88 100644 --- a/src/web/src/hooks/community/use-channel-route-model.subscription.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-route-model.subscription.dom.test.ts @@ -35,15 +35,8 @@ vi.mock("@tanstack/react-query", async () => { vi.mock("./use-servers", () => ({ useServer: () => ({ server: mocks.server }), })) -vi.mock("./use-child-channel-meta", () => ({ - useChildChannelMeta: ( - _serverId: string, - _channelId: string, - _enabled: boolean, - placeholderData?: Record, - ) => placeholderData && !mocks.metaQuery.isVerified - ? { ...mocks.metaQuery, data: placeholderData, isVerified: true } - : mocks.metaQuery, +vi.mock("./use-channel-metadata", () => ({ + useChannelMetadata: () => mocks.metaQuery, })) vi.mock("./use-community-ws", () => ({ communityWsSubscribe: (...args: unknown[]) => mocks.subscribe(...args), @@ -129,8 +122,10 @@ describe("useChannelRouteModel subscription ownership", () => { renderer!.rerender(React.createElement(Harness, { channelId: "forum-1" })) }) + expect(lifecycle(renderer!)).toBe("pending") + mocks.metaQuery.isVerified = true + act(() => renderer!.rerender(React.createElement(Harness, { channelId: "forum-1" }))) expect(lifecycle(renderer!)).toBe("ready") - expect(mocks.metaQuery.isVerified).toBe(false) act(() => renderer!.unmount()) }) @@ -143,6 +138,7 @@ describe("useChannelRouteModel subscription ownership", () => { }], } + mocks.metaQuery.isVerified = true const renderer = render(React.createElement(Harness, { channelId: "text-1" })) const node = renderer.container.querySelector("span") @@ -165,7 +161,7 @@ describe("useChannelRouteModel subscription ownership", () => { }, ) - it("hydrates a canonical thread placeholder with nullable child fields", () => { + it("keeps a restored structural thread pending until its Channel resource is qualified", () => { mocks.dbChannel = { id: "post-1", serverId: "server-1", @@ -180,11 +176,13 @@ describe("useChannelRouteModel subscription ownership", () => { const renderer = render(React.createElement(Harness)) + expect(renderer.container.querySelector("span")).toHaveAttribute("data-lifecycle", "pending") + expect(useCommunityStore.getState().currentChannelMeta).toBeNull() + mocks.metaQuery = { data: { ...mocks.dbChannel, creatorId: null, lastMessageAt: null, createdAt: "", archived: false }, + error: null, isVerified: true, isError: false } + act(() => renderer.rerender(React.createElement(Harness))) expect(renderer.container.querySelector("span")).toHaveAttribute("data-lifecycle", "ready") - expect(useCommunityStore.getState().currentChannelMeta).toMatchObject({ - creatorId: null, - activityAt: "", - }) + expect(useCommunityStore.getState().currentChannelMeta).toMatchObject({ creatorId: null, activityAt: "" }) act(() => renderer.unmount()) }) diff --git a/src/web/src/hooks/community/use-channel-route-model.ts b/src/web/src/hooks/community/use-channel-route-model.ts index 46b714cee..c6ac674c8 100644 --- a/src/web/src/hooks/community/use-channel-route-model.ts +++ b/src/web/src/hooks/community/use-channel-route-model.ts @@ -16,7 +16,8 @@ import { consumeCommunityColdEntryFailure, } from "@/lib/community/last-community-route" import { communityWsSubscribe, communityWsUnsubscribe } from "./use-community-ws" -import { useChildChannelMeta } from "./use-child-channel-meta" +import { useChannelMetadata } from "./use-channel-metadata" +import type { ChildChannelMeta } from "./use-forum-sidebar-threads" import { removeForumSidebarThreadExact, removeForumSidebarUnreadChild, @@ -26,7 +27,7 @@ import { useOptionalCommunityDbRegistry } from "@/lib/community-db/projections" import { purgeCommunityChannel } from "@/lib/community-db/sync" type Server = ReturnType["server"] -type ChannelMeta = NonNullable["data"]> | null +type ChannelMeta = ChildChannelMeta | null export function buildChannelRouteModel( server: Server, @@ -77,24 +78,14 @@ export function useChannelRouteModel( ?.flatMap((category) => category.channels) .some((candidate) => candidate.id === channelId) const isChild = !!server?.categories && !topLevelChannel - const cachedChildMeta = useMemo(() => dbChannel?.type === "thread" - && dbChannel.parentChannelId - && dbChannel.parentMessageId ? { - id: dbChannel.id, - serverId, - name: dbChannel.name, - type: dbChannel.type, - parentChannelId: dbChannel.parentChannelId, - parentMessageId: dbChannel.parentMessageId, - creatorId: dbChannel.creatorId ?? null, - archived: dbChannel.archived, - activityAt: dbChannel.lastMessageAt ?? "", - verifiedEpoch: accessEpoch, - } : undefined, [accessEpoch, dbChannel, serverId]) - const metaQuery = useChildChannelMeta(serverId, channelId, isChild, cachedChildMeta) - const renderableChannelMeta = isChild && metaQuery.isVerified - ? (metaQuery.data ?? null) - : cachedChildMeta ?? null + const metaQuery = useChannelMetadata(serverId, channelId) + const renderableChannelMeta: ChannelMeta = useMemo(() => isChild && metaQuery.isVerified + && metaQuery.data?.serverId === serverId && metaQuery.data.parentChannelId && metaQuery.data.parentMessageId + ? { ...metaQuery.data, serverId, + parentChannelId: metaQuery.data.parentChannelId, + parentMessageId: metaQuery.data.parentMessageId, + activityAt: metaQuery.data.lastMessageAt ?? metaQuery.data.createdAt } + : null, [isChild, metaQuery.data, metaQuery.isVerified, serverId]) const retryScope = JSON.stringify([accountId, serverId, channelId, accessEpoch]) const retryAttemptRef = useRef<{ scope: string } | null>(null) const [retryAttempt, setRetryAttempt] = useState<{ scope: string } | null>(null) @@ -102,7 +93,7 @@ export function useChannelRouteModel( const metadataExit = isDefinitiveChildMetaFailure(metaQuery.error) || (metaQuery.error instanceof ApiError && metaQuery.error.status === 401) || !!metaQuery.data?.archived - const metadataError = isChild && !metaQuery.isVerified && !metadataExit + const metadataError = !metaQuery.isVerified && !metadataExit && (metaQuery.isError || retryingMetadata) const retryMetadata = useCallback(async () => { if (!metadataError || metaQuery.isFetching || retryAttemptRef.current?.scope === retryScope) return @@ -129,11 +120,11 @@ export function useChannelRouteModel( ) const routeLifecycle = !server?.categories ? "pending" as const - : !isChild + : !isChild && metaQuery.isVerified ? "ready" as const - : metadataExit || (metaQuery.isError && !model.routeHydrated) + : metadataExit || (metaQuery.isError && !metaQuery.isVerified) ? "terminal-error" as const - : model.routeHydrated + : model.routeHydrated && metaQuery.isVerified ? "ready" as const : "pending" as const const skeletonSubtype = routeLifecycle === "ready" @@ -158,11 +149,11 @@ export function useChannelRouteModel( return () => communityWsUnsubscribe() }, [channelId]) useEffect(() => { - if (!isChild) { + const denied = isDefinitiveChildMetaFailure(metaQuery.error) + if (!isChild && !denied && !metaQuery.data?.archived) { useCommunityStore.getState().setCurrentChannelMeta(null) return } - const denied = isDefinitiveChildMetaFailure(metaQuery.error) if (denied || metaQuery.data?.archived) { const store = useCommunityStore.getState() const routeStillCurrent = store.currentChannelId === channelId @@ -185,15 +176,16 @@ export function useChannelRouteModel( ? COMMUNITY_COLD_ENTRY_FALLBACK : `/c/channels/${serverParam}` router.replace(destination) - } else if (metaQuery.data && metaQuery.isVerified) { - useCommunityStore.getState().setCurrentChannelMeta(metaQuery.data) + } else if (renderableChannelMeta) { + useCommunityStore.getState().setCurrentChannelMeta(renderableChannelMeta) } else if (metaQuery.error) { useCommunityStore.getState().setCurrentChannelMeta(null) - toastApiError(metaQuery.error, "Failed to load thread") + toastApiError(metaQuery.error, "Failed to load channel") } - }, [accountId, channelId, communityDb, isChild, metaQuery.data, metaQuery.error, metaQuery.isVerified, queryClient, router, serverId, serverParam]) + }, [accountId, channelId, communityDb, isChild, metaQuery.data, metaQuery.error, metaQuery.isVerified, queryClient, renderableChannelMeta, router, serverId, serverParam]) return { ...model, + routeHydrated: model.routeHydrated && metaQuery.isVerified, routeLifecycle, skeletonSubtype, metadataError, diff --git a/src/web/src/hooks/community/use-child-channel-meta.ts b/src/web/src/hooks/community/use-child-channel-meta.ts index 1044aeab1..86f9a91b6 100644 --- a/src/web/src/hooks/community/use-child-channel-meta.ts +++ b/src/web/src/hooks/community/use-child-channel-meta.ts @@ -2,28 +2,23 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" import { useEffect, useState } from "react" -import { fetchChannelMetadata, type ChannelMetadata } from "@/hooks/community/channel-metadata" -import { communityKeys } from "@/lib/query-keys" +import { channelMetadataOptions, type ChannelMetadata } from "@/hooks/community/channel-metadata" import type { ChildChannelMeta } from "@/hooks/community/use-forum-sidebar-threads" import { useCommunityWsStore } from "@/stores/community/ws" -import { ApiError } from "@/lib/errors" +import { captureChannelMetadataToken } from "./channel-metadata" import { useOptionalCommunityDbRegistry, useRouteChannelProjection, } from "@/lib/community-db/projections" -import { - captureCommunityLiveSnapshotToken, - publishCommunityChannelMetadata, -} from "@/lib/community-db/sync" function projectChildMeta(payload: ChannelMetadata, verifiedEpoch: number): ChildChannelMeta { - if (!payload.parentChannelId || !payload.parentMessageId) { + if (!payload.serverId || !payload.parentChannelId || !payload.parentMessageId) { throw new Error("invalid child channel metadata") } return { id: payload.id, serverId: payload.serverId, - name: payload.name, + name: payload.name ?? "", type: payload.type, parentChannelId: payload.parentChannelId, parentMessageId: payload.parentMessageId, @@ -99,24 +94,18 @@ export function useChildChannelMeta( verifiedEpoch: accessEpoch, } : undefined - const query = useQuery({ - queryKey: communityKeys.channelMeta(serverId, channelId), - queryFn: async ({ signal }) => { - const token = captureCommunityLiveSnapshotToken(queryClient) - const meta = await fetchChannelMetadata(serverId, channelId, signal) - publishCommunityChannelMetadata(queryClient, { - metadata: meta, - proof: { token, signal }, - }) - return projectChildMeta(meta, meta.verifiedEpoch) - }, + const query = useQuery({ + ...channelMetadataOptions(queryClient, serverId, channelId), + select: (meta) => projectChildMeta(meta, meta.verifiedEpoch), enabled, - placeholderData: registry ? undefined : placeholderData, - staleTime: Infinity, - gcTime: 5 * 60 * 1000, - retry: (failureCount, error) => - !(error instanceof ApiError && [401, 403, 404].includes(error.status)) - && failureCount < 1, + placeholderData: registry || !placeholderData ? undefined : { + ...placeholderData, + creatorId: placeholderData.creatorId ?? null, + lastMessageAt: placeholderData.activityAt, + createdAt: placeholderData.activityAt, + verification: captureChannelMetadataToken(channelId), + historyVerification: undefined, + }, }) const [trusted, setTrusted] = useState(null) useEffect(() => { diff --git a/src/web/src/hooks/community/use-dm-read-state.test.ts b/src/web/src/hooks/community/use-dm-read-state.test.ts index c5fe4b11c..457d78551 100644 --- a/src/web/src/hooks/community/use-dm-read-state.test.ts +++ b/src/web/src/hooks/community/use-dm-read-state.test.ts @@ -35,7 +35,9 @@ let queryReturn: { data: unknown; isFetching: boolean } = { } vi.mock("@tanstack/react-query", () => ({ useQuery: (_config: unknown) => queryReturn, + useQueryClient: () => ({}), })) +vi.mock("./channel-metadata", () => ({ captureChannelMetadataToken: vi.fn(), isChannelMetadataTokenCurrent: vi.fn() })) vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn(), @@ -129,7 +131,7 @@ describe("useDmReadStateSnapshot — freeze invariant", () => { } const warm = useHook("dm_1", canonical) flushEffects() - expect(warm).toEqual({ snapshot: canonical, isFetching: false }) + expect(warm).toMatchObject({ snapshot: canonical, isFetching: false }) refCounter = 0 pendingEffects = [] diff --git a/src/web/src/hooks/community/use-dm-read-state.ts b/src/web/src/hooks/community/use-dm-read-state.ts index fbf15c688..99c60cd51 100644 --- a/src/web/src/hooks/community/use-dm-read-state.ts +++ b/src/web/src/hooks/community/use-dm-read-state.ts @@ -1,9 +1,10 @@ "use client" import { useEffect, useRef } from "react" -import { useQuery } from "@tanstack/react-query" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { apiFetch } from "@/lib/api/client" import { communityKeys } from "@/lib/query-keys" +import { captureChannelMetadataToken, isChannelMetadataTokenCurrent, type ChannelMetadata } from "./channel-metadata" /** * The DM read-state snapshot returned by @@ -39,15 +40,37 @@ export function useDmReadStateSnapshot( ): { snapshot: DmReadStateSnapshot | null isFetching: boolean + error: Error | null + retrying: boolean + retry: () => void } { + const queryClient = useQueryClient() const query = useQuery({ queryKey: dmId ? communityKeys.dmReadStateSnapshot(dmId) : ["community", "dm", "__none__", "read-state-snapshot"], - queryFn: async () => { - return apiFetch( - `/api/community/channels/${dmId}/read-state`, - ) + queryFn: async ({ signal }) => { + const token = captureChannelMetadataToken(dmId!) + const metadataKey = communityKeys.channelMeta(null, dmId!) + const metadataQuery = queryClient.getQueryCache().find({ queryKey: metadataKey, exact: true }) + const current = () => !signal.aborted && isChannelMetadataTokenCurrent(token) + try { + const snapshot = await apiFetch(`/api/community/channels/${dmId}/read-state`, { signal }) + if (!current()) throw new DOMException("Stale DM history access", "AbortError") + if (metadataQuery && queryClient.getQueryCache().find({ queryKey: metadataKey, exact: true }) === metadataQuery) { + queryClient.setQueryData(metadataKey, (metadata) => metadata + ? { ...metadata, historyVerification: token } : metadata) + } + return snapshot + } catch (error) { + if (!current()) throw new DOMException("Stale DM history access", "AbortError") + if (typeof error === "object" && error !== null && "status" in error && (error.status === 403 || error.status === 404) + && metadataQuery && queryClient.getQueryCache().find({ queryKey: metadataKey, exact: true }) === metadataQuery) { + queryClient.setQueryData(metadataKey, (metadata) => metadata + ? { ...metadata, historyVerification: undefined } : metadata) + } + throw error + } }, enabled: !!dmId, staleTime: Infinity, @@ -60,7 +83,8 @@ export function useDmReadStateSnapshot( refetchOnMount: "always", refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: 1, + retry: (failureCount, error) => error.name !== "AbortError" + && !("status" in error && [403, 404].includes(Number(error.status))) && failureCount < 1, }) const snapshotRef = useRef(null) @@ -84,6 +108,9 @@ export function useDmReadStateSnapshot( return { snapshot: snapshotRef.current ?? (!query.isFetching ? (query.data ?? null) : null), isFetching: snapshotRef.current === null && query.isFetching, + error: query.error, + retrying: query.isFetching, + retry: () => { void query.refetch({ cancelRefetch: false }) }, } /* eslint-enable react-hooks/refs */ } diff --git a/src/web/src/hooks/community/use-dm-route-verification.dom.test.ts b/src/web/src/hooks/community/use-dm-route-verification.dom.test.ts index e12232d09..090efaa4c 100644 --- a/src/web/src/hooks/community/use-dm-route-verification.dom.test.ts +++ b/src/web/src/hooks/community/use-dm-route-verification.dom.test.ts @@ -1,554 +1,146 @@ import React from "react" import { onlineManager, QueryClient, QueryClientProvider } from "@tanstack/react-query" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { act, render } from "@/test/react-dom-harness" +import { act, renderHook, waitFor } from "@/test/react-dom-harness" import { communityKeys } from "@/lib/query-keys" -import type { DM } from "@/lib/community/models/people" import { useCommunityWsStore } from "@/stores/community/ws" -import { - classifyDmRouteAuthorityError, - DM_ROUTE_VERIFICATION_HEADER, - startDmRouteVerification, - useDmRouteVerification, - type DmRouteVerificationResult, - type DmRouteVerificationStatus, -} from "./use-dm-route-verification" +import { channelMetadataOptions } from "./channel-metadata" +import { startDmRouteVerification, useDmRouteVerification } from "./use-dm-route-verification" -const apiFetchMock = vi.hoisted(() => vi.fn()) - -vi.mock("@/lib/api/client", () => ({ - apiFetch: (...args: unknown[]) => apiFetchMock(...args), -})) - -function client() { - return new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, - }) -} +const apiFetch = vi.hoisted(() => vi.fn()) +vi.mock("@/lib/api/client", () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })) +const dm = { id: "dm-a", userId: "peer", name: "Peer", discriminator: "0001", avatar: "P", status: "offline" as const, preview: "" } +const metadata = { id: dm.id, serverId: null, type: "dm", name: null, parentChannelId: null, + parentMessageId: null, creatorId: null, archived: false, lastMessageAt: null, createdAt: "2026-10-02T00:00:00Z" } +const denied = () => Object.assign(new Error("missing"), { status: 404 }) function deferred() { let resolve!: (value: T) => void - let reject!: (reason: unknown) => void - const promise = new Promise((done, fail) => { - resolve = done - reject = fail - }) - return { promise, reject, resolve } + const promise = new Promise((done) => { resolve = done }) + return { promise, resolve } } - -function Capture({ - dmId, - dms, - canonicalUnsettled = false, - onRender, - onResult, -}: { - dmId: string | undefined - dms: readonly DM[] - canonicalUnsettled?: boolean - onRender: (status: DmRouteVerificationStatus) => void - onResult?: (result: DmRouteVerificationResult) => void -}) { - const result = useDmRouteVerification(dmId, dms, canonicalUnsettled) - onRender(result.status) - onResult?.(result) - return null -} - -async function renderHook( - queryClient: QueryClient, - props: React.ComponentProps, -) { - return render(React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, props), - ), - )) -} - -async function waitFor(predicate: () => boolean, tries = 80) { - for (let attempt = 0; attempt < tries; attempt += 1) { - if (predicate()) return - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 5)) - }) - } - expect(predicate()).toBe(true) +function fixture(strict = false) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, gcTime: Infinity } } }) + const wrapper = ({ children }: React.PropsWithChildren) => React.createElement(QueryClientProvider, + { client }, strict ? React.createElement(React.StrictMode, null, children) : children) + return { client, wrapper } } -describe("DM route verification", () => { - beforeEach(() => { - apiFetchMock.mockReset() - useCommunityWsStore.getState().reset() - useCommunityWsStore.getState().activateProfileAccount("viewer") - }) - afterEach(() => onlineManager.setOnline(true)) - - it("bypasses a fresh cached miss, updates the canonical list, and dedupes callers", async () => { - const queryClient = client() - queryClient.setQueryData(communityKeys.dms(), { conversations: [] }) - const authoritative = { - conversations: [{ - id: "dm-new", - userId: "u-new", - name: "New peer", - discriminator: "2222", - avatar: "N", - status: "offline" as const, - preview: "", - }], - } - apiFetchMock.mockResolvedValue(authoritative) - - const [first, second] = await Promise.all([ - startDmRouteVerification(queryClient, "dm-new"), - startDmRouteVerification(queryClient, "dm-new"), - ]) - - expect(first).toBe("present") - expect(second).toBe("present") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - expect(apiFetchMock).toHaveBeenCalledWith("/api/community/users/me/dms", { - headers: { [DM_ROUTE_VERIFICATION_HEADER]: "1" }, - signal: expect.any(AbortSignal), - }) - expect(queryClient.getQueryData(communityKeys.dms())).toEqual(authoritative) - }) - - it("confirms a fresh missing target with one authority request", async () => { - const queryClient = client() - queryClient.setQueryData(communityKeys.dms(), { conversations: [] }) - apiFetchMock.mockResolvedValue({ conversations: [] }) - - await expect(startDmRouteVerification(queryClient, "dm-missing")).resolves.toBe("missing") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - }) - - it.each(["cancel", "account", "access"] as const)( - "rejects a %s-superseded route authority response before canonical cache publication", - async (race) => { - const queryClient = client() - const current: DM = { - id: "dm-current", - userId: "u-current", - name: "Current peer", - discriminator: "0001", - avatar: "C", - status: "offline", - preview: "", - } - queryClient.setQueryData(communityKeys.dms(), { conversations: [current] }) - const request = deferred<{ conversations: DM[] }>() - apiFetchMock.mockReturnValueOnce(request.promise) - - const pending = startDmRouteVerification(queryClient, "dm-stale-target") - const rejected = expect(pending).rejects.toMatchObject( - race === "cancel" ? {} : { name: "AbortError" }, - ) - await vi.waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(1)) - if (race === "cancel") { - await queryClient.cancelQueries({ - queryKey: communityKeys.dmRouteVerification("dm-stale-target"), - exact: true, - }) - } else if (race === "account") { - useCommunityWsStore.getState().activateProfileAccount("other") - } else { - useCommunityWsStore.setState((state) => ({ accessEpoch: state.accessEpoch + 1 })) - } - request.resolve({ conversations: [] }) - - await rejected - expect(queryClient.getQueryData(communityKeys.dms())).toEqual({ - conversations: [current], - }) - }, - ) - - it("trusts a provisional canonical cache row without starting authority", async () => { - const queryClient = client() - const provisional: DM = { - id: "dm-provisional", - userId: "u-provisional", - name: "Provisional peer", - discriminator: "4444", - avatar: "P", - status: "offline", - preview: "", - } - queryClient.setQueryData(communityKeys.dms(), { conversations: [provisional] }) - - await expect(startDmRouteVerification(queryClient, provisional.id)).resolves.toBe("present") - expect(apiFetchMock).not.toHaveBeenCalled() - }) - - it("trusts a projected canonical row while the layout observer snapshot lags", async () => { - const queryClient = client() - const projected: DM = { - id: "dm-projected", - userId: "u-projected", - name: "Projected peer", - discriminator: "5555", - avatar: "P", - status: "offline", - preview: "", - } - queryClient.setQueryData(communityKeys.dms(), { conversations: [projected] }) - apiFetchMock.mockResolvedValue({ conversations: [] }) - const statuses: DmRouteVerificationStatus[] = [] - const renderer = await renderHook(queryClient, { - dmId: projected.id, - dms: [], - onRender: (status) => statuses.push(status), - }) - - expect(statuses.at(-1)).toBe("present") - expect(apiFetchMock).not.toHaveBeenCalled() - renderer.unmount() - }) - - it.each([403, 404])("classifies explicit %s as denied", async (status) => { - expect(classifyDmRouteAuthorityError({ status })).toBe("denied") - }) - - it("returns denied for an explicit authority rejection", async () => { - const queryClient = client() - apiFetchMock.mockRejectedValueOnce(Object.assign(new Error("denied"), { status: 403 })) - - await expect(startDmRouteVerification(queryClient, "dm-denied")).resolves.toBe("denied") - expect(queryClient.getQueryData(communityKeys.dms())).toBeUndefined() - }) - - it("does not classify a transient failure as denied", () => { - expect(classifyDmRouteAuthorityError({ status: 0 })).toBe("error") - expect(classifyDmRouteAuthorityError(new Error("offline"))).toBe("error") - }) - - it("fetches and settles a cold missing route under Strict Mode", async () => { - const queryClient = client() - const request = deferred<{ conversations: DM[] }>() - apiFetchMock.mockReturnValue(request.promise) - const statuses: DmRouteVerificationStatus[] = [] - const renderer = await renderHook(queryClient, { - dmId: "dm-missing", - dms: [], - onRender: (status) => statuses.push(status), - }) - - await waitFor(() => apiFetchMock.mock.calls.length === 1) - expect(statuses).toContain("pending") - await act(async () => request.resolve({ conversations: [] })) - await waitFor(() => statuses.at(-1) === "missing") - - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("waits for the canonical DMs query before verifying an absent route", async () => { - const queryClient = client() - apiFetchMock.mockResolvedValue({ conversations: [] }) - const statuses: DmRouteVerificationStatus[] = [] - const props = { - dmId: "dm-missing", - dms: [], - canonicalUnsettled: true, - onRender: (status: DmRouteVerificationStatus) => statuses.push(status), - } - const renderer = await renderHook(queryClient, props) - - expect(statuses.at(-1)).toBe("pending") - expect(apiFetchMock).not.toHaveBeenCalled() - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { ...props, canonicalUnsettled: false }), - ), - ), - ) - }) - await waitFor(() => statuses.at(-1) === "missing") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("waits for a stale canonical refetch that eventually contains the route", async () => { - const queryClient = client() - const canonical: DM = { - id: "dm-refetched", - userId: "u-refetched", - name: "Refetched peer", - discriminator: "3333", - avatar: "R", - status: "offline", - preview: "", - } - const statuses: DmRouteVerificationStatus[] = [] - const props = { - dmId: canonical.id, - dms: [], - canonicalUnsettled: true, - onRender: (status: DmRouteVerificationStatus) => statuses.push(status), - } - const renderer = await renderHook(queryClient, props) - - expect(statuses.at(-1)).toBe("pending") - expect(apiFetchMock).not.toHaveBeenCalled() - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { - ...props, - dms: [canonical], - canonicalUnsettled: false, - }), - ), - ), - ) - }) - - expect(statuses.at(-1)).toBe("present") - expect(apiFetchMock).not.toHaveBeenCalled() - renderer.unmount() - }) - - it("starts one authority request after a stale canonical refetch remains absent", async () => { - const queryClient = client() - apiFetchMock.mockResolvedValue({ conversations: [] }) - const statuses: DmRouteVerificationStatus[] = [] - const props = { - dmId: "dm-still-missing", - dms: [], - canonicalUnsettled: true, - onRender: (status: DmRouteVerificationStatus) => statuses.push(status), - } - const renderer = await renderHook(queryClient, props) - - expect(statuses.at(-1)).toBe("pending") - expect(apiFetchMock).not.toHaveBeenCalled() - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { ...props, canonicalUnsettled: false }), - ), - ), - ) - }) - await waitFor(() => statuses.at(-1) === "missing") - - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("shares a click-started request with the route observer", async () => { - const queryClient = client() - const request = deferred<{ conversations: DM[] }>() - const provisional: DM = { - id: "dm-new", - userId: "u-new", - name: "New peer", - discriminator: "2222", - avatar: "N", - status: "offline", - preview: "", - } - apiFetchMock.mockReturnValue(request.promise) - const started = startDmRouteVerification(queryClient, provisional.id) - const statuses: DmRouteVerificationStatus[] = [] - const renderer = await renderHook(queryClient, { - dmId: provisional.id, - dms: [provisional], - onRender: (status) => statuses.push(status), - }) - - await waitFor(() => apiFetchMock.mock.calls.length === 1) - expect(statuses.at(-1)).toBe("present") - await act(async () => request.resolve({ conversations: [provisional] })) - await expect(started).resolves.toBe("present") - await waitFor(() => statuses.at(-1) === "present") - - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("surfaces a transient failure and retries only the current verification", async () => { - const queryClient = client() - const statuses: DmRouteVerificationStatus[] = [] - let latest!: DmRouteVerificationResult - apiFetchMock.mockRejectedValueOnce(Object.assign(new Error("offline"), { status: 0 })) - const renderer = await renderHook(queryClient, { - dmId: "dm-offline", - dms: [], - onRender: (status) => statuses.push(status), - onResult: (result) => { latest = result }, - }) - - await waitFor(() => statuses.at(-1) === "error") - expect(statuses).not.toContain("missing") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - - apiFetchMock.mockResolvedValueOnce({ conversations: [] }) +beforeEach(() => { + apiFetch.mockReset() + useCommunityWsStore.getState().reset() + useCommunityWsStore.getState().activateProfileAccount("viewer") +}) +afterEach(() => onlineManager.setOnline(true)) + +describe("DM route uses the shared Channel metadata owner", () => { + it("shares one exact Channel request between imperative and route consumers without another DM-list request", async () => { + const { client, wrapper } = fixture(true) + client.setQueryData(communityKeys.dms(), { conversations: [] }) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const first = startDmRouteVerification(client, dm.id) + const second = startDmRouteVerification(client, dm.id) + const route = renderHook(() => useDmRouteVerification(dm.id), { wrapper }) + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + expect(route.result.current.status).toBe("pending") + await act(async () => request.resolve(metadata)) + await expect(first).resolves.toBe("present") + await expect(second).resolves.toBe("present") + await waitFor(() => expect(route.result.current.status).toBe("present")) + expect(apiFetch).toHaveBeenCalledWith(`/api/community/channels/${dm.id}`, { signal: expect.any(AbortSignal) }) + expect(client.getQueryData(communityKeys.channelMeta(null, dm.id))).toMatchObject({ ...metadata, name: "" }) + expect(client.getQueryData(communityKeys.dms())).toEqual({ conversations: [] }) + expect(useCommunityWsStore.getState().channelAccessScopes.size).toBe(0) + route.unmount() + client.clear() + }) + + it("reuses the same metadata Query when another Channel consumer has already fetched it", async () => { + const { client, wrapper } = fixture() + apiFetch.mockResolvedValue(metadata) + await client.fetchQuery(channelMetadataOptions(client, null, dm.id)) + const route = renderHook(() => useDmRouteVerification(dm.id), { wrapper }) + expect(route.result.current.status).toBe("present") + await expect(startDmRouteVerification(client, dm.id)).resolves.toBe("present") + expect(apiFetch).toHaveBeenCalledOnce() + route.unmount() + client.clear() + }) + + it("a manually cached peer list cannot qualify the target Channel", async () => { + const { client, wrapper } = fixture() + client.setQueryData(communityKeys.dms(), { conversations: [dm] }) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => useDmRouteVerification(dm.id), { wrapper }) + expect(route.result.current.status).toBe("pending") + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + await act(async () => request.resolve(metadata)) + await waitFor(() => expect(route.result.current.status).toBe("present")) + route.unmount() + client.clear() + }) + + it.each([403, 404])("keeps explicit %s terminal and rejects scope mismatches", async (status) => { + const { client } = fixture() + apiFetch.mockRejectedValueOnce(Object.assign(new Error("denied"), { status })) + await expect(startDmRouteVerification(client, dm.id)).resolves.toBe("denied") + apiFetch.mockResolvedValueOnce({ ...metadata, serverId: "server", type: "text" }) + await expect(startDmRouteVerification(client, "other")).rejects.toThrow("scope mismatch") + expect(client.getQueryData(communityKeys.dms())).toBeUndefined() + client.clear() + }) + + it("keeps a transient error local across a remount/reconnect and retries only its Channel resource", async () => { + const { client, wrapper } = fixture() + apiFetch.mockRejectedValueOnce(new Error("offline")) + const hook = ({ key }: { key: string }) => React.createElement(Capture, { key }) + let latest!: ReturnType + function Capture() { latest = useDmRouteVerification(dm.id); return null } + const { render } = await import("@/test/react-dom-harness") + const route = render(hook({ key: "first" }), { wrapper }) + await waitFor(() => expect(latest.status).toBe("error")) + route.rerender(hook({ key: "second" })) + await act(async () => { onlineManager.setOnline(false); onlineManager.setOnline(true) }) + expect(latest.status).toBe("error") + expect(apiFetch).toHaveBeenCalledOnce() + apiFetch.mockResolvedValueOnce(metadata) await act(async () => latest.retry()) - await waitFor(() => statuses.at(-1) === "missing") - expect(apiFetchMock).toHaveBeenCalledTimes(2) - renderer.unmount() - }) - - it("keeps a transient failure local across remount and network reconnect", async () => { - const queryClient = client() - const statuses: DmRouteVerificationStatus[] = [] - apiFetchMock.mockRejectedValueOnce(Object.assign(new Error("offline"), { status: 0 })) - apiFetchMock.mockResolvedValue({ conversations: [] }) - const props = { - dmId: "dm-offline-remount", - dms: [], - onRender: (status: DmRouteVerificationStatus) => statuses.push(status), - } - const renderer = await renderHook(queryClient, props) - - await waitFor(() => statuses.at(-1) === "error") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { ...props, key: "remounted" }), - ), - ), - ) - await new Promise((resolve) => setTimeout(resolve, 20)) - }) - - expect(statuses.at(-1)).toBe("error") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - - await act(async () => { - onlineManager.setOnline(false) - onlineManager.setOnline(true) - await new Promise((resolve) => setTimeout(resolve, 20)) - }) - - expect(statuses.at(-1)).toBe("error") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("keeps a transient failure local across a canonical background refetch", async () => { - const queryClient = client() - const statuses: DmRouteVerificationStatus[] = [] - apiFetchMock.mockRejectedValueOnce(Object.assign(new Error("offline"), { status: 0 })) - apiFetchMock.mockResolvedValue({ conversations: [] }) - const props = { - dmId: "dm-offline-canonical-refetch", - dms: [], - onRender: (status: DmRouteVerificationStatus) => statuses.push(status), - } - const renderer = await renderHook(queryClient, props) - - await waitFor(() => statuses.at(-1) === "error") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { ...props, canonicalUnsettled: true }), - ), - ), - ) - await new Promise((resolve) => setTimeout(resolve, 0)) - }) - expect(statuses.at(-1)).toBe("error") - - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { ...props, canonicalUnsettled: false }), - ), - ), - ) - await new Promise((resolve) => setTimeout(resolve, 20)) - }) - - expect(statuses.at(-1)).toBe("error") - expect(apiFetchMock).toHaveBeenCalledTimes(1) - renderer.unmount() - }) - - it("stays idle without a route and trusts an existing canonical row", async () => { - const queryClient = client() - const canonical: DM = { - id: "dm-existing", - userId: "u-existing", - name: "Existing peer", - discriminator: "1111", - avatar: "E", - status: "online", - preview: "hello", - } - const statuses: DmRouteVerificationStatus[] = [] - const renderer = await renderHook(queryClient, { - dmId: undefined, - dms: [], - onRender: (status) => statuses.push(status), - }) - - expect(statuses.at(-1)).toBe("idle") - await act(async () => { - renderer.rerender( - React.createElement( - React.StrictMode, - null, - React.createElement( - QueryClientProvider, - { client: queryClient }, - React.createElement(Capture, { - dmId: canonical.id, - dms: [canonical], - onRender: (status) => statuses.push(status), - }), - ), - ), - ) - }) - - expect(statuses.at(-1)).toBe("present") - expect(apiFetchMock).not.toHaveBeenCalled() - renderer.unmount() + await waitFor(() => expect(latest.status).toBe("present")) + expect(apiFetch).toHaveBeenCalledTimes(2) + route.unmount() + client.clear() + }) + + it.each(["cancel", "account", "access"] as const)("rejects an old successful metadata response after %s", async (race) => { + const { client } = fixture() + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const started = startDmRouteVerification(client, dm.id) + const rejection = expect(started).rejects.toBeDefined() + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + if (race === "cancel") await client.cancelQueries({ queryKey: communityKeys.channelMeta(null, dm.id), exact: true }) + else if (race === "account") useCommunityWsStore.getState().activateProfileAccount("other") + else useCommunityWsStore.setState((state) => ({ accessEpoch: state.accessEpoch + 1 })) + request.resolve(metadata) + await rejection + expect(client.getQueryData(communityKeys.channelMeta(null, dm.id))).toBeUndefined() + expect(useCommunityWsStore.getState().channelAccessScopes.size).toBe(0) + client.clear() + }) + + it("releases the last route observer and aborts its metadata request", async () => { + const { client, wrapper } = fixture() + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => useDmRouteVerification(dm.id), { wrapper }) + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + const signal = apiFetch.mock.calls[0][1].signal as AbortSignal + route.unmount() + expect(signal.aborted).toBe(true) + await act(async () => request.resolve(metadata)) + expect(client.getQueryData(communityKeys.channelMeta(null, dm.id))).toBeUndefined() + client.clear() }) }) diff --git a/src/web/src/hooks/community/use-dm-route-verification.ts b/src/web/src/hooks/community/use-dm-route-verification.ts index b835135bf..fa43a24bc 100644 --- a/src/web/src/hooks/community/use-dm-route-verification.ts +++ b/src/web/src/hooks/community/use-dm-route-verification.ts @@ -1,149 +1,74 @@ "use client" -import { useCallback, useEffect } from "react" -import { - useQuery, - useQueryClient, - QueryObserver, - type QueryClient, - type QueryFunctionContext, -} from "@tanstack/react-query" -import { apiFetch } from "@/lib/api/client" -import { communityKeys } from "@/lib/query-keys" -import type { DM } from "@/lib/community/models/people" -import type { DmsResponse } from "./use-dms" +import { QueryObserver, type QueryClient } from "@tanstack/react-query" +import { useCallback } from "react" +import { channelMetadataOptions, captureChannelMetadataToken, isChannelMetadataTokenCurrent } from "./channel-metadata" +import { useChannelMetadata } from "./use-channel-metadata" import { assertCommunityLiveSnapshotTokenCurrent, captureCommunityLiveSnapshotToken, - publishCommunityLiveSnapshot, } from "@/lib/community-db/sync" -export const DM_ROUTE_VERIFICATION_HEADER = "X-Alook-DM-Route-Verification" - -const dmRouteAuthorityQueryFn = (signal: AbortSignal | undefined) => apiFetch( - "/api/community/users/me/dms", - { headers: { [DM_ROUTE_VERIFICATION_HEADER]: "1" }, signal }, -) - export type DmRouteVerification = "present" | "missing" | "denied" -export type DmRouteVerificationStatus = "idle" | "pending" | "present" | "missing" | "error" +type DmRouteVerificationStatus = "idle" | "pending" | "present" | "missing" | "error" export type DmRouteVerificationResult = { status: DmRouteVerificationStatus retry: () => void retrying: boolean } -export function classifyDmRouteAuthorityError(error: unknown): "denied" | "error" { +function classifyDmRouteAuthorityError(error: unknown): "denied" | "error" { const status = typeof error === "object" && error !== null && "status" in error ? error.status : undefined return status === 403 || status === 404 ? "denied" : "error" } -async function verifyDmRoute( - queryClient: QueryClient, - dmId: string, - signal: AbortSignal | undefined, -): Promise { - const token = captureCommunityLiveSnapshotToken(queryClient) - try { - const response = await dmRouteAuthorityQueryFn(signal) - publishCommunityLiveSnapshot(queryClient, { - snapshot: { kind: "dms", data: response }, - proof: { kind: "structural", token, signal }, - }) - assertCommunityLiveSnapshotTokenCurrent(queryClient, token, signal) - queryClient.setQueryData(communityKeys.dms(), response) - return response.conversations.some((dm) => dm.id === dmId) ? "present" : "missing" - } catch (error) { - if (classifyDmRouteAuthorityError(error) === "denied") return "denied" - throw error - } -} - function verificationOptions(queryClient: QueryClient, dmId: string) { - const queryKey = communityKeys.dmRouteVerification(dmId) return { - queryKey, - queryFn: async ({ signal }: QueryFunctionContext) => { - const owner = new QueryObserver(queryClient, { queryKey, enabled: false }) - const release = owner.subscribe(() => release()) - return verifyDmRoute(queryClient, dmId, signal) - }, + ...channelMetadataOptions(queryClient, null, dmId), retry: false, refetchOnReconnect: false, refetchOnWindowFocus: false, - // The error frame owns retry UX. Route/layout remounts must preserve a - // transient failure instead of silently issuing another authority check. retryOnMount: false, } as const } -export function startDmRouteVerification( +export async function startDmRouteVerification( queryClient: QueryClient, dmId: string, ): Promise { - const canonical = queryClient.getQueryData(communityKeys.dms()) - if (canonical?.conversations.some((dm) => dm.id === dmId)) { - return Promise.resolve("present") + const options = verificationOptions(queryClient, dmId) + const owner = new QueryObserver(queryClient, { ...options, enabled: false }) + const release = owner.subscribe(() => {}) + const token = captureCommunityLiveSnapshotToken(queryClient) + try { + const cached = queryClient.getQueryData<{ verification?: ReturnType }>(options.queryKey) + await queryClient.fetchQuery({ ...options, + staleTime: cached?.verification && isChannelMetadataTokenCurrent(cached.verification) ? Infinity : 0 }) + return "present" + } catch (error) { + assertCommunityLiveSnapshotTokenCurrent(queryClient, token, undefined) + if (classifyDmRouteAuthorityError(error) === "denied") return "denied" + throw error + } finally { + release() } - return queryClient.fetchQuery({ - ...verificationOptions(queryClient, dmId), - staleTime: 0, - }) } export function useDmRouteVerification( dmId: string | undefined, - dms: readonly DM[], - canonicalUnsettled: boolean, ): DmRouteVerificationResult { - const queryClient = useQueryClient() - const canonical = queryClient.getQueryData(communityKeys.dms()) - // Inbox navigation writes the destination into the canonical cache before - // routing. The layout's observer snapshot can trail that synchronous write - // by one render, so consult the cache directly before starting authority. - const present = !!dmId && ( - dms.some((dm) => dm.id === dmId) - || canonical?.conversations.some((dm) => dm.id === dmId) === true - ) - const options = verificationOptions(queryClient, dmId ?? "__none__") - const retainedVerification = queryClient.getQueryState(options.queryKey) - const attemptStarted = retainedVerification !== undefined && ( - retainedVerification.fetchStatus === "fetching" - || retainedVerification.dataUpdatedAt > 0 - || retainedVerification.errorUpdatedAt > 0 - ) - const verification = useQuery({ - ...options, - enabled: !!dmId && !present && (!canonicalUnsettled || attemptStarted), - staleTime: Infinity, - }) + const verification = useChannelMetadata(null, dmId) const retry = useCallback(() => { if (!dmId || verification.fetchStatus === "fetching") return - void verification.refetch() + void verification.refetch({ cancelRefetch: false }) }, [dmId, verification]) - useEffect(() => { - if (!dmId) return - return () => { - const queryKey = communityKeys.dmRouteVerification(dmId) - queueMicrotask(() => { - const query = queryClient.getQueryCache().find({ queryKey, exact: true }) - if (query?.getObserversCount() !== 0) return - queryClient.removeQueries({ queryKey, exact: true }) - }) - } - }, [dmId, queryClient]) - let status: DmRouteVerificationStatus = "pending" if (!dmId) status = "idle" - else if (present || verification.data === "present") status = "present" - else if (verification.data === "missing" || verification.data === "denied") status = "missing" + else if (classifyDmRouteAuthorityError(verification.error) === "denied" || verification.data?.archived || verification.canonical?.archived) status = "missing" + else if (verification.isVerified && verification.data?.type === "dm") status = "present" else if (verification.isError) status = "error" - return { - status, - retry, - retrying: verification.fetchStatus === "fetching", - } + return { status, retry, retrying: verification.fetchStatus === "fetching" } } diff --git a/src/web/src/lib/community-db/sync.ts b/src/web/src/lib/community-db/sync.ts index 79a0a132d..58d19185d 100644 --- a/src/web/src/lib/community-db/sync.ts +++ b/src/web/src/lib/community-db/sync.ts @@ -853,8 +853,8 @@ function ingestChannelMetadata( registry: CommunityDbRegistry, metadata: { id: string - serverId: string - name: string + serverId: string | null + name: string | null type: string parentChannelId: string | null parentMessageId: string | null @@ -865,14 +865,15 @@ function ingestChannelMetadata( openerUnread?: boolean }, ) { - if (!(["text", "forum", "thread"] as const).includes(metadata.type as "text")) return + if (!["dm", "text", "forum", "thread"].includes(metadata.type)) return const existing = collectionRows(registry, "channels", channelSchema) .find((row) => row.id === metadata.id) upsertRows(registry, "channels", channelSchema, (row) => row.id, [{ + ...existing, id: metadata.id, serverId: metadata.serverId, categoryId: existing?.categoryId ?? null, - name: metadata.name, + name: metadata.name ?? "", type: metadata.type as ChannelRow["type"], parentChannelId: metadata.parentChannelId, parentMessageId: metadata.parentMessageId, @@ -1802,12 +1803,15 @@ export function publishCommunityLiveSnapshot( break case "server-detail": ingestServerDetail(registry, snapshot.data) + qualifyCanonicalChannelMetadata(registry, + snapshot.data.categories.flatMap((category) => category.channels.map((channel) => channel.id)), proof.token) break case "folders": ingestFolders(registry, snapshot.data) break case "dms": ingestDms(registry, snapshot.data) + qualifyCanonicalChannelMetadata(registry, snapshot.data.conversations.map((dm) => dm.id), proof.token) break case "read-state": ingestReadStateSnapshot(registry, snapshot.data) @@ -1902,8 +1906,8 @@ export function publishCommunityEmbeddedMessages( export type CommunityChannelMetadata = { id: string - serverId: string - name: string + serverId: string | null + name: string | null type: string parentChannelId: string | null parentMessageId: string | null @@ -1912,6 +1916,37 @@ export type CommunityChannelMetadata = { lastMessageAt: string | null } +function qualifyCanonicalChannelMetadata(registry: CommunityDbRegistry, channelIds: readonly string[], token: CommunityLiveSnapshotToken) { + if (!registry.accountId) return + for (const channelId of channelIds) { + const channel = registry.collections.channels.get(channelId) + const membership = registry.collections.channelMemberships.get(channelMembershipKey(channelId, registry.accountId, "access")) + if (!channel || channel.pending || channel.archived || !membership) continue + registry.queryClient.setQueryData(communityKeys.channelMeta(channel.serverId ?? null, channelId), (previous: CommunityChannelMetadata | undefined) => ({ + ...previous, + id: channel.id, + serverId: channel.serverId ?? null, + name: channel.name, + type: channel.type, + parentChannelId: channel.parentChannelId ?? null, + parentMessageId: channel.parentMessageId ?? null, + creatorId: channel.creatorId ?? null, + archived: channel.archived, + lastMessageAt: channel.lastMessageAt ?? null, + createdAt: channel.lastMessageAt ?? "", + activityAt: channel.lastMessageAt ?? "", + verifiedEpoch: token.accessEpoch, + verification: { + viewerId: token.viewerId, + accountEpoch: token.accountEpoch, + accessEpoch: token.accessEpoch, + channelId, + generation: useCommunityWsStore.getState().channelAccessScopes.get(channelId)?.generation ?? 0, + }, + })) + } +} + /** Publish one verified channel metadata response and its viewer access fact. */ export function publishCommunityChannelMetadata( queryClient: QueryClient, diff --git a/src/web/src/lib/community/conversation-navigation-warmup.test.ts b/src/web/src/lib/community/conversation-navigation-warmup.test.ts index 092c2a4c6..43eff8203 100644 --- a/src/web/src/lib/community/conversation-navigation-warmup.test.ts +++ b/src/web/src/lib/community/conversation-navigation-warmup.test.ts @@ -185,7 +185,7 @@ describe("conversation navigation warmup", () => { } queryClient.setQueryData(communityKeys.dmMessages("d1"), { stale: true }) queryClient.setQueryData(communityKeys.dmReadStateSnapshot("d1"), { stale: true }) - queryClient.setQueryData(communityKeys.dmRouteVerification("d1"), "present") + queryClient.setQueryData(communityKeys.channelMeta(null, "d1"), "present") startConversationNavigationWarmup(queryClient, dmTarget, 2) expect(mocks.requests[0]).toMatchObject({ kind: "dm", pageParam: { mode: "newest" } }) expect(mocks.servers).toHaveLength(0) @@ -196,7 +196,7 @@ describe("conversation navigation warmup", () => { expect(queryClient.getQueryData(communityKeys.dmMessages("d1"))).toBeUndefined() expect(queryClient.getQueryData(communityKeys.dmReadStateSnapshot("d1"))).toBeUndefined() - expect(queryClient.getQueryData(communityKeys.dmRouteVerification("d1"))).toBeUndefined() + expect(queryClient.getQueryData(communityKeys.channelMeta(null, "d1"))).toBeUndefined() expect(mocks.removeScope).toHaveBeenCalledWith({ kind: "dm", id: "d1" }) expect(mocks.reads[0]!.signal?.aborted).toBe(true) mocks.reads[0]!.resolve({ lastReadMessageId: "late", lastReadAt: null, lastReadSeq: 8 }) diff --git a/src/web/src/lib/community/conversation-navigation-warmup.ts b/src/web/src/lib/community/conversation-navigation-warmup.ts index 81397fce2..13bcc8b3f 100644 --- a/src/web/src/lib/community/conversation-navigation-warmup.ts +++ b/src/web/src/lib/community/conversation-navigation-warmup.ts @@ -40,7 +40,7 @@ function clearDeniedTarget(queryClient: QueryClient, target: ConversationNavigat if (target.serverId) { queryClient.removeQueries({ queryKey: communityKeys.channelMeta(target.serverId, target.channelId) }) } else { - queryClient.removeQueries({ queryKey: communityKeys.dmRouteVerification(target.channelId) }) + queryClient.removeQueries({ queryKey: communityKeys.channelMeta(null, target.channelId) }) } useMessageStreamStore.getState().removeScope( target.scopeKind === "dm" diff --git a/src/web/src/lib/community/last-channel.test.ts b/src/web/src/lib/community/last-channel.test.ts index 165964a01..20d59ba39 100644 --- a/src/web/src/lib/community/last-channel.test.ts +++ b/src/web/src/lib/community/last-channel.test.ts @@ -4,13 +4,31 @@ import { getLastChannel, setLastChannel, clearLastChannel, - pickServerLandingChannel, pickServerLandingHref, + resolveCommunityLandingHref, } from "./last-channel" describe("last-channel", () => { let storage: Record + it.each([ + [null, "dm_1", "desktop", "/c/me/dm_1"], + [null, "bots", "desktop", "/c/me/bots"], + [null, null, "desktop", "/c/me/friends"], + [null, "dm_1", "mobile", "/c/me"], + [null, "dm_1", "unknown", "/c/me"], + ["server", "child", "desktop", "/c/channels/server/child"], + ["server", null, "desktop", "/c/channels/server/first"], + ["server", "child", "mobile", "/c/channels/server"], + ["server", "child", "unknown", "/c/channels/server"], + [null, "../dm", "desktop", "/c/me/friends"], + ["server", "child?msg=42", "desktop", "/c/channels/server/first"], + ["server", "child#marker", "desktop", "/c/channels/server/first"], + ["server", "..", "desktop", "/c/channels/server/first"], + ] as const)("resolves scope %s and leaf %s for %s", (serverId, last, breakpoint, expected) => { + expect(resolveCommunityLandingHref({ serverId, last, breakpoint, channelIds: ["first"] })).toBe(expected) + }) + beforeEach(() => { storage = {} vi.unstubAllGlobals() @@ -108,37 +126,6 @@ describe("last-channel", () => { }) }) -describe("pickServerLandingChannel", () => { - it("restores the remembered last channel (returned directly, not validated against the list)", () => { - expect(pickServerLandingChannel(["ch_1", "ch_2", "ch_3"], "ch_2")).toBe("ch_2") - }) - - it("restores a remembered id that is NOT in the top-level list — a forum post / thread (the bug this fixes)", () => { - // A post/thread is a child channel: its id never appears in the top-level - // list, so the old `includes` gate dropped it and always fell to default. - // Now it's returned directly; the destination page renders the post opener. - expect(pickServerLandingChannel(["ch_1", "ch_2"], "post_abc")).toBe("post_abc") - }) - - it("falls back to the first top-level channel when there is no memory", () => { - expect(pickServerLandingChannel(["ch_1", "ch_2"], null)).toBe("ch_1") - }) - - it("returns a dirty/garbage remembered id as-is — validity is the destination's job (its meta fetch 404/403 → bounce to default)", () => { - // "Trust the id" ≠ "trust the id is valid". A localStorage value hand-edited - // or written stale by another tab still navigates; the destination page's - // 404||403 bounce degrades it to default (Blondie/Melly's dirty-last check). - expect(pickServerLandingChannel(["ch_1", "ch_2"], "garbage_id")).toBe("garbage_id") - }) - - it("returns undefined only when there's no memory AND no channels; a remembered id wins even over an empty list", () => { - expect(pickServerLandingChannel([], null)).toBeUndefined() - // With memory, the id is returned even if the top-level list is empty (the - // remembered channel may be a child channel under a forum, not top-level). - expect(pickServerLandingChannel([], "post_abc")).toBe("post_abc") - }) -}) - describe("pickServerLandingHref", () => { it("rejects a superseded parent/child leaf and falls back safely", () => { expect(pickServerLandingHref("srv_1", ["ch_1"], "forum_1/post_1")).toBe( diff --git a/src/web/src/lib/community/last-channel.ts b/src/web/src/lib/community/last-channel.ts index e0aa401ef..bf9075a92 100644 --- a/src/web/src/lib/community/last-channel.ts +++ b/src/web/src/lib/community/last-channel.ts @@ -53,40 +53,30 @@ export function clearLastChannel(serverId: string): void { clearNavigationMemory(lastChannelKey(serverId)) } -/** - * Pick which channel the server-landing page should redirect to: the remembered - * `last` channel when there is one, else the first top-level channel by position. - * `undefined` when the server has no channels and there's no memory. - * - * `last` is trusted and returned directly — it is NOT validated against - * `channelIds`. `channelIds` (the server's TOP-LEVEL channels) is only the - * source for the DEFAULT when there's no memory. This is deliberate: a - * remembered channel may be a FORUM POST or THREAD (a child channel — its id - * lives under a forum/parent, never in the top-level list), so validating `last` - * against the top-level list would drop every remembered post/thread and always - * fall back to default (the bug this fixes). Instead the landing page navigates - * to `last` and the destination `[channelId]` page validates it: a real - * child-channel renders its opener view; a deleted / no-longer-accessible / - * outright-garbage id fails its meta fetch and bounces back to default (see the - * 404||403 bounce in that page). "Trust the id" ≠ "trust the id is valid" — - * validity is the destination's job, not this selector's. Extracted pure so the - * rule is unit-tested without a page-render harness. - */ -export function pickServerLandingChannel( - channelIds: readonly string[], - last: string | null, -): string | undefined { - if (last !== null && !last.includes("/")) return last - return channelIds[0] -} - export function pickServerLandingHref( serverId: string, channelIds: readonly string[], last: string | null, ): string { - const channelId = pickServerLandingChannel(channelIds, last) - return channelId - ? `/c/channels/${serverId}/${channelId}` - : `/c/channels/${serverId}` + return resolveCommunityLandingHref({ serverId, channelIds, last }) +} + +export function resolveCommunityLandingHref({ + serverId, + channelIds = [], + last, + breakpoint = "desktop", +}: { + serverId: string | null + channelIds?: readonly string[] + last: string | null + breakpoint?: "unknown" | "desktop" | "mobile" +}): string { + const root = serverId === null ? "/c/me" : `/c/channels/${encodeURIComponent(serverId)}` + if (breakpoint !== "desktop") return root + const safeLast = last && last !== "." && last !== ".." && !/[\\/?#\s]/.test(last) + ? last + : null + const leaf = safeLast ?? (serverId === null ? "friends" : channelIds[0]) + return leaf ? `${root}/${encodeURIComponent(leaf)}` : root } diff --git a/src/web/src/lib/community/last-community-route.test.ts b/src/web/src/lib/community/last-community-route.test.ts index 474878303..d7ec6f42a 100644 --- a/src/web/src/lib/community/last-community-route.test.ts +++ b/src/web/src/lib/community/last-community-route.test.ts @@ -4,12 +4,14 @@ import { canonicalCommunityLeafPathname, clearCommunityColdEntryAttempts, commitLastCommunityRoute, + commitCommunityChannelRoute, consumeCommunityColdEntryFailure, getLastCommunityRoute, lastCommunityRouteKey, retireCommunityColdEntryAttempt, resolveCommunityColdEntryDestination, } from "./last-community-route" +import { getLastChannel } from "./last-channel" describe("last-community-route", () => { let storage: Record @@ -35,6 +37,17 @@ describe("last-community-route", () => { expect(getLastCommunityRoute("user-b")).toBe("/c/me/machines") }) + it("commits the qualified target to both account and scope navigation memories", () => { + expect(commitCommunityChannelRoute("user-a", null, "dm_1")).toBe("/c/me/dm_1") + expect(getLastCommunityRoute("user-a")).toBe("/c/me/dm_1") + expect(getLastChannel("me")).toBe("dm_1") + expect(commitCommunityChannelRoute("user-b", "server_1", "channel_1")).toBe("/c/channels/server_1/channel_1") + expect(getLastCommunityRoute("user-a")).toBe("/c/me/dm_1") + expect(getLastChannel("server_1")).toBe("channel_1") + expect(commitCommunityChannelRoute("", null, "dm_2")).toBeNull() + expect(getLastChannel("me")).toBe("dm_1") + }) + it.each([ ["/c/me/friends", "/c/me/friends"], ["/c/me/machines?reconnect=1#daemon", "/c/me/machines"], diff --git a/src/web/src/lib/community/last-community-route.ts b/src/web/src/lib/community/last-community-route.ts index a042f3ed1..e01665f64 100644 --- a/src/web/src/lib/community/last-community-route.ts +++ b/src/web/src/lib/community/last-community-route.ts @@ -1,4 +1,5 @@ import { resolveCommunityModulePlan } from "./community-route" +import { setLastChannel } from "./last-channel" import { clearNavigationMemory, readNavigationMemory, @@ -52,6 +53,15 @@ export function commitLastCommunityRoute(accountId: string, href: string): strin return canonical } +export function commitCommunityChannelRoute(accountId: string, serverId: string | null, channelId: string) { + const href = serverId === null + ? `/c/me/${encodeURIComponent(channelId)}` + : `/c/channels/${encodeURIComponent(serverId)}/${encodeURIComponent(channelId)}` + const canonical = commitLastCommunityRoute(accountId, href) + if (canonical) setLastChannel(serverId ?? "me", channelId) + return canonical +} + export function resolveCommunityColdEntryDestination({ accountId, pathname, diff --git a/src/web/src/lib/community/last-me-location.test.ts b/src/web/src/lib/community/last-me-location.test.ts index b35a89507..9de62ed90 100644 --- a/src/web/src/lib/community/last-me-location.test.ts +++ b/src/web/src/lib/community/last-me-location.test.ts @@ -5,7 +5,6 @@ import { isRememberableMeLocation, lastMeLocationKey, pickMeLandingLocation, - resolveMeLocationStatus, setLastMeLocation, } from "./last-me-location" @@ -65,47 +64,3 @@ describe("last-me-location", () => { expect(pickMeLandingLocation("dm_1")).toBe("/c/me/dm_1") }) }) - -describe("resolveMeLocationStatus", () => { - it("remembers static surfaces without waiting for DMs", () => { - expect(resolveMeLocationStatus({ - pathname: "/c/me/machines", - dmId: undefined, - dmRouteStatus: "idle", - })).toBe("remember") - }) - - it("waits for an unresolved DM list", () => { - expect(resolveMeLocationStatus({ - pathname: "/c/me/dm_1", - dmId: "dm_1", - dmRouteStatus: "pending", - })).toBe("wait") - expect(resolveMeLocationStatus({ - pathname: "/c/me/dm_1", - dmId: "dm_1", - dmRouteStatus: "error", - })).toBe("wait") - }) - - it("remembers a confirmed DM and marks a missing DM stale", () => { - expect(resolveMeLocationStatus({ - pathname: "/c/me/dm_1", - dmId: "dm_1", - dmRouteStatus: "present", - })).toBe("remember") - expect(resolveMeLocationStatus({ - pathname: "/c/me/dm_missing", - dmId: "dm_missing", - dmRouteStatus: "missing", - })).toBe("stale") - }) - - it("ignores paths outside the supported me route shape", () => { - expect(resolveMeLocationStatus({ - pathname: "/c/me/dm_1/messages", - dmId: "dm_1", - dmRouteStatus: "present", - })).toBe("ignore") - }) -}) diff --git a/src/web/src/lib/community/last-me-location.ts b/src/web/src/lib/community/last-me-location.ts index c983c4c0c..e5f01146a 100644 --- a/src/web/src/lib/community/last-me-location.ts +++ b/src/web/src/lib/community/last-me-location.ts @@ -3,13 +3,11 @@ import { getLastChannel, lastChannelKey, setLastChannel, + resolveCommunityLandingHref, } from "./last-channel" const ME_SCOPE_ID = "me" export const ME_ROOT = "/c/me" -const FRIENDS_LEAF = "friends" - -export type MeLocationStatus = "ignore" | "wait" | "remember" | "stale" function isSafeMeLeaf(leaf: string): boolean { return leaf !== "." && leaf !== ".." && !/[\/\\?#\s]/.test(leaf) @@ -19,11 +17,16 @@ export function lastMeLocationKey(): string { return lastChannelKey(ME_SCOPE_ID) } -export function meLeafFromPathname(pathname: string): string | null { +function meLeafFromPathname(pathname: string): string | null { const prefix = `${ME_ROOT}/` if (!pathname.startsWith(prefix)) return null const leaf = pathname.slice(prefix.length) - return leaf && isSafeMeLeaf(leaf) ? leaf : null + try { + const decoded = decodeURIComponent(leaf) + return decoded && isSafeMeLeaf(decoded) ? decoded : null + } catch { + return null + } } export function isRememberableMeLocation(pathname: string): boolean { @@ -45,24 +48,5 @@ export function clearLastMeLocation(): void { } export function pickMeLandingLocation(lastLeaf: string | null): string { - if (lastLeaf === null || !lastLeaf || !isSafeMeLeaf(lastLeaf)) { - return `${ME_ROOT}/${FRIENDS_LEAF}` - } - return `${ME_ROOT}/${lastLeaf}` -} - -export function resolveMeLocationStatus({ - pathname, - dmId, - dmRouteStatus, -}: { - pathname: string - dmId: string | undefined - dmRouteStatus: "idle" | "pending" | "present" | "missing" | "error" -}): MeLocationStatus { - if (!isRememberableMeLocation(pathname)) return "ignore" - if (!dmId) return "remember" - if (dmRouteStatus === "present") return "remember" - if (dmRouteStatus === "missing") return "stale" - return "wait" + return resolveCommunityLandingHref({ serverId: null, last: lastLeaf }) } diff --git a/src/web/src/lib/query-keys.test.ts b/src/web/src/lib/query-keys.test.ts index 1f1670851..c3b8a468d 100644 --- a/src/web/src/lib/query-keys.test.ts +++ b/src/web/src/lib/query-keys.test.ts @@ -132,10 +132,11 @@ describe("communityKeys", () => { it("keys top-level social/machine feeds directly under all", () => { expect(communityKeys.friends()).toEqual(["community", "friends"]) expect(communityKeys.dms()).toEqual(["community", "dms"]) - expect(communityKeys.dmRouteVerification("d1")).toEqual([ + expect(communityKeys.channelMeta(null, "d1")).toEqual([ "community", - "dm-route-verification", + "channel", "d1", + "metadata", ]) expect(communityKeys.folders()).toEqual(["community", "folders"]) expect(communityKeys.machines()).toEqual(["community", "machines"]) diff --git a/src/web/src/lib/query-keys.ts b/src/web/src/lib/query-keys.ts index 92b26b4eb..05ef56560 100644 --- a/src/web/src/lib/query-keys.ts +++ b/src/web/src/lib/query-keys.ts @@ -34,8 +34,9 @@ export const communityKeys = { [...communityKeys.forumSidebarRetainedRoot(serverId), childId] as const, channelMetaRoot: (serverId: string) => [...communityKeys.server(serverId), "channel-meta"] as const, - channelMeta: (serverId: string, channelId: string) => - [...communityKeys.channelMetaRoot(serverId), channelId] as const, + channelMeta: (serverId: string | null, channelId: string) => serverId === null + ? [...communityKeys.all, "channel", channelId, "metadata"] as const + : [...communityKeys.channelMetaRoot(serverId), channelId] as const, forumOpenerHintRoot: (serverId: string) => [...communityKeys.server(serverId), "forum-opener-hint"] as const, forumOpenerHint: (serverId: string, messageId: string) => @@ -140,8 +141,6 @@ export const communityKeys = { friends: () => [...communityKeys.all, "friends"] as const, friendsPresence: () => [...communityKeys.friends(), "presence"] as const, dms: () => [...communityKeys.all, "dms"] as const, - dmRouteVerification: (dmId: string) => - [...communityKeys.all, "dm-route-verification", dmId] as const, folders: () => [...communityKeys.all, "folders"] as const, // ── Machines / daemons ────────────────────────────────────────────────── diff --git a/src/web/src/test/architecture/community-channel-route-contract.test.ts b/src/web/src/test/architecture/community-channel-route-contract.test.ts index ed3bd3b7e..ec6a5ca0c 100644 --- a/src/web/src/test/architecture/community-channel-route-contract.test.ts +++ b/src/web/src/test/architecture/community-channel-route-contract.test.ts @@ -58,7 +58,7 @@ describe("flat Community channel route contract", () => { join(SRC_ROOT, "lib/community/last-channel.ts"), "utf8", ) - expect(source.match(/includes\("\/"\)/g)).toHaveLength(3) + expect(source.match(/includes\("\/"\)/g)).toHaveLength(2) expect(source).toContain("clearNavigationMemory(key)") }) }) diff --git a/src/web/src/test/e2e-ui/04-dm.spec.ts b/src/web/src/test/e2e-ui/04-dm.spec.ts index 42acbf7c2..1e6520d47 100644 --- a/src/web/src/test/e2e-ui/04-dm.spec.ts +++ b/src/web/src/test/e2e-ui/04-dm.spec.ts @@ -5,7 +5,6 @@ import { sendMessage } from "./_fixtures/actions" import { proxyCommunityWebSockets } from "./_fixtures/community-ws-proxy" import { seedDm, seedBlock, seedDmMessage } from "./_fixtures/seed" import { captureNotificationRequests, gotoAfterNotificationStartup, notificationPaths, notificationResponsesFinished } from "./_fixtures/community-notification-requests" -import { DM_ROUTE_VERIFICATION_HEADER } from "@/hooks/community/use-dm-route-verification" import { lastMeLocationKey } from "@/lib/community/last-me-location" // Journey 4 — DMs. human↔human needs only not-blocked (no friendship). Covers @@ -183,25 +182,18 @@ test.describe.serial("direct messages", () => { await alice.page.addInitScript((storageKey) => localStorage.removeItem(storageKey), lastMeLocationKey()) const missingDmId = `dm-missing-${Date.now()}` let canonicalDmsGets = 0 - let authorityDmsGets = 0 + let metadataGets = 0 const dmsPattern = "**/api/community/users/me/dms" + const metadataPattern = `**/api/community/channels/${missingDmId}` await alice.page.route(dmsPattern, async (route) => { - if (route.request().method() !== "GET") { - await route.continue() - return - } - if (route.request().headers()[DM_ROUTE_VERIFICATION_HEADER.toLowerCase()] !== "1") { - canonicalDmsGets += 1 - await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ conversations: [] }) }) - return - } - authorityDmsGets += 1 - if (authorityDmsGets === 1) { - await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "temporary" }) }) - return - } + canonicalDmsGets += 1 await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ conversations: [] }) }) }) + await alice.page.route(metadataPattern, async (route) => { + metadataGets += 1 + await route.fulfill({ status: metadataGets === 1 ? 503 : 404, + contentType: "application/json", body: JSON.stringify({ error: metadataGets === 1 ? "temporary" : "missing" }) }) + }) try { await alice.page.goto(`/c/me/${missingDmId}`) @@ -217,15 +209,16 @@ test.describe.serial("direct messages", () => { await expect.poll(() => canonicalDmsGets).toBe(1) await expect(verificationAlert).toBeVisible() await expect(alice.page).toHaveURL(new RegExp(`/c/me/${missingDmId}$`)) - expect(authorityDmsGets).toBe(1) + expect(metadataGets).toBe(1) await verificationAlert.getByRole("button", { name: "Retry" }).click() - await expect.poll(() => authorityDmsGets).toBe(2) + await expect.poll(() => metadataGets).toBe(2) expect(canonicalDmsGets).toBe(1) await expect.poll(() => new URL(alice.page.url()).pathname).toBe("/c/me/friends") } finally { wsProxy.releaseHeldConnections() await alice.page.unroute(dmsPattern) + await alice.page.unroute(metadataPattern) } }) From 046875751b3bf658e4cd4d6bf5fab6bff3aa8882 Mon Sep 17 00:00:00 2001 From: Gener <39689863+GenerQAQ@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:56:11 +0800 Subject: [PATCH 2/3] fix(web): preserve cold DM fallback and qualify restored content --- .../app/c/me/layout.route-memory.dom.test.ts | 9 +++ src/web/src/app/c/me/layout.tsx | 5 +- .../community/channels/dm-route.dom.test.ts | 16 +++- .../community/channels/dm-route.tsx | 15 ++-- .../use-channel-metadata.dom.test.ts | 47 ++++++++++++ .../lib/community/last-me-location.test.ts | 7 ++ src/web/src/test/e2e-ui/04-dm.spec.ts | 11 ++- ...6-server-switch-pending-checkpoint.spec.ts | 6 +- ...mmunity-cache-first-warm-reload.journey.ts | 76 +++++++++++++++++-- 9 files changed, 170 insertions(+), 22 deletions(-) diff --git a/src/web/src/app/c/me/layout.route-memory.dom.test.ts b/src/web/src/app/c/me/layout.route-memory.dom.test.ts index 7a0794233..55a809d65 100644 --- a/src/web/src/app/c/me/layout.route-memory.dom.test.ts +++ b/src/web/src/app/c/me/layout.route-memory.dom.test.ts @@ -172,6 +172,15 @@ describe("MeLayout route memory", () => { client.clear() }) + it("does not commit a DM pathname while its layout params are still unresolved", () => { + mocks.pathname = "/c/me/dm-pending" + mocks.dmId = undefined + mocks.locationStatus = "remember" + renderLayout() + expect(mocks.setLastMeLocation).not.toHaveBeenCalled() + expect(mocks.commitLastCommunityRoute).not.toHaveBeenCalled() + }) + it("keeps the Friends shortcut count on the shared terminal projection through failed refreshes", async () => { const request = { id: "friendship-a", diff --git a/src/web/src/app/c/me/layout.tsx b/src/web/src/app/c/me/layout.tsx index 29e6376de..c16fffec8 100644 --- a/src/web/src/app/c/me/layout.tsx +++ b/src/web/src/app/c/me/layout.tsx @@ -52,12 +52,13 @@ export default function MeLayout({ children }: { children: ReactNode }) { const machinesActive = pathname === "/c/me/machines" const botsActive = pathname === "/c/me/bots" const friendsActive = pathname === "/c/me/friends" + const staticModuleActive = machinesActive || botsActive || friendsActive useEffect(() => { - if (params.dmId || !isRememberableMeLocation(pathname)) return + if (params.dmId || !staticModuleActive || !isRememberableMeLocation(pathname)) return setLastMeLocation(pathname) commitLastCommunityRoute(currentUser.id, pathname) - }, [currentUser.id, params.dmId, pathname]) + }, [currentUser.id, params.dmId, pathname, staticModuleActive]) // Navigation is intentionally read-neutral. The visible-row observer owns // both optimistic clearing and the durable cursor write. diff --git a/src/web/src/components/community/channels/dm-route.dom.test.ts b/src/web/src/components/community/channels/dm-route.dom.test.ts index dfb01d872..7cabfece3 100644 --- a/src/web/src/components/community/channels/dm-route.dom.test.ts +++ b/src/web/src/components/community/channels/dm-route.dom.test.ts @@ -20,7 +20,12 @@ vi.mock("@/lib/community-db/sync", () => ({ purgeCommunityChannel: mocks.purge } vi.mock("@/lib/community/last-me-location", () => ({ ME_ROOT: "/c/me", getLastMeLeaf: () => mocks.last, clearLastMeLocation: mocks.clear })) vi.mock("@/lib/community/last-community-route", () => ({ COMMUNITY_COLD_ENTRY_FALLBACK: "/c/me/machines", - consumeCommunityColdEntryFailure: (...args: unknown[]) => { mocks.consume(...args); return mocks.cold }, + consumeCommunityColdEntryFailure: (...args: unknown[]) => { + mocks.consume(...args) + const cold = mocks.cold + mocks.cold = false + return cold + }, })) vi.mock("./dm-view", () => ({ DmView: ({ dmId }: { dmId: string }) => React.createElement("main", { "data-channel-id": dmId }, dmId) })) vi.mock("./dm-loading-frame", () => ({ DmLoadingFrame: ({ reserveBackSlot }: { reserveBackSlot: boolean }) => React.createElement("div", { "data-testid": "dm-pending", "data-back": String(reserveBackSlot) }) })) @@ -74,4 +79,13 @@ describe("DM target main owns identity, retry and fallback", () => { render(React.createElement(DmRoute, { dmId: "dm-a" })) expect(mocks.clear).not.toHaveBeenCalled() }) + + it("keeps a consumed cold fallback through Strict Mode effect replay", () => { + mocks.verification.status = "missing" + mocks.cold = true + render(React.createElement(React.StrictMode, null, React.createElement(DmRoute, { dmId: "dm-a" }))) + expect(mocks.replace).toHaveBeenCalledExactlyOnceWith("/c/me/machines") + expect(mocks.consume).toHaveBeenCalledOnce() + expect(mocks.purge).toHaveBeenCalledOnce() + }) }) diff --git a/src/web/src/components/community/channels/dm-route.tsx b/src/web/src/components/community/channels/dm-route.tsx index 667632c6e..af236a9b4 100644 --- a/src/web/src/components/community/channels/dm-route.tsx +++ b/src/web/src/components/community/channels/dm-route.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect } from "react" +import { useEffect, useRef } from "react" import { useRouter } from "next/navigation" import { DmView } from "./dm-view" import { DmRouteErrorFrame } from "./dm-route-error-frame" @@ -23,15 +23,20 @@ export function DmRoute({ dmId }: { dmId: string }) { const breakpoint = useBreakpoint() const registry = useOptionalCommunityDbRegistry() const verification = useDmRouteVerification(dmId) + const exitedTarget = useRef(null) useEffect(() => { if (verification.status !== "missing") return + const target = JSON.stringify([currentUser.id, dmId]) + if (exitedTarget.current === target) return + exitedTarget.current = target + const href = `/c/me/${encodeURIComponent(dmId)}` + const destination = consumeCommunityColdEntryFailure(currentUser.id, href) + ? COMMUNITY_COLD_ENTRY_FALLBACK + : ME_ROOT if (registry) purgeCommunityChannel(registry, dmId) if (getLastMeLeaf() === dmId) clearLastMeLocation() useCommunityStore.getState().uiHandlers.cancelPendingNavigation?.() - const href = `/c/me/${encodeURIComponent(dmId)}` - router.replace(consumeCommunityColdEntryFailure(currentUser.id, href) - ? COMMUNITY_COLD_ENTRY_FALLBACK - : ME_ROOT) + router.replace(destination) }, [currentUser.id, dmId, registry, router, verification.status]) if (verification.status === "error") { diff --git a/src/web/src/hooks/community/use-channel-metadata.dom.test.ts b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts index 65196a111..d657fe4f1 100644 --- a/src/web/src/hooks/community/use-channel-metadata.dom.test.ts +++ b/src/web/src/hooks/community/use-channel-metadata.dom.test.ts @@ -52,6 +52,33 @@ afterEach(async () => { }) describe("shared Channel resource and canonical DM publication", () => { + it("leaves an absent target disabled without starting a metadata request", async () => { + const { client, wrapper } = await fixture() + const route = renderHook(() => useChannelMetadata(null, undefined), { wrapper }) + expect(route.result.current.isVerified).toBe(false) + expect(route.result.current.fetchStatus).toBe("idle") + expect(client.getQueryState(communityKeys.channelMeta(null, "__none__"))?.fetchStatus).toBe("idle") + expect(apiFetch).not.toHaveBeenCalled() + route.unmount() + }) + + it("invalidates expired qualification and waits for a current metadata receipt", async () => { + const { registry, wrapper } = await fixture() + publishDms(registry) + const request = deferred() + apiFetch.mockReturnValue(request.promise) + const route = renderHook(() => useChannelMetadata(null, metadata.id), { wrapper }) + expect(route.result.current.isVerified).toBe(true) + expect(apiFetch).not.toHaveBeenCalled() + act(() => useCommunityWsStore.setState((state) => ({ accessEpoch: state.accessEpoch + 1 }))) + expect(route.result.current.isVerified).toBe(false) + await waitFor(() => expect(apiFetch).toHaveBeenCalledOnce()) + await act(async () => request.resolve(metadata)) + await waitFor(() => expect(route.result.current.isVerified).toBe(true)) + expect(isChannelMetadataTokenCurrent(route.result.current.data!.verification!)).toBe(true) + route.unmount() + }) + it("publishes a real nullable DM response without losing peer closure or granting a server scope", async () => { const { client, registry, wrapper } = await fixture() ingestDms(registry, dms) @@ -98,6 +125,26 @@ describe("shared Channel resource and canonical DM publication", () => { }) describe("DM history permission stays separate from metadata identity", () => { + it("keeps a transient read error local and grants history after explicit Retry succeeds", async () => { + const { registry, wrapper } = await fixture(new QueryClient({ + defaultOptions: { queries: { retryDelay: 0, gcTime: Infinity } }, + })) + publishDms(registry) + apiFetch.mockRejectedValue(new Error("offline")) + const route = renderHook(() => ({ metadata: useChannelMetadata(null, metadata.id), read: useDmReadStateSnapshot(metadata.id) }), { wrapper }) + await waitFor(() => expect(route.result.current.read.error?.message).toBe("offline")) + expect(apiFetch).toHaveBeenCalledTimes(2) + expect(route.result.current.metadata.isVerified).toBe(true) + expect(route.result.current.metadata.data?.historyVerification).toBeUndefined() + apiFetch.mockResolvedValue(readState) + act(() => route.result.current.read.retry()) + await waitFor(() => expect(route.result.current.read.snapshot).toEqual(readState)) + expect(route.result.current.read.error).toBeNull() + expect(isChannelMetadataTokenCurrent(route.result.current.metadata.data!.historyVerification!)).toBe(true) + expect(apiFetch).toHaveBeenCalledTimes(3) + route.unmount() + }) + it("records only a current read permission receipt on the existing Channel resource", async () => { const { client, registry, wrapper } = await fixture() publishDms(registry) diff --git a/src/web/src/lib/community/last-me-location.test.ts b/src/web/src/lib/community/last-me-location.test.ts index 9de62ed90..70a02e502 100644 --- a/src/web/src/lib/community/last-me-location.test.ts +++ b/src/web/src/lib/community/last-me-location.test.ts @@ -47,6 +47,7 @@ describe("last-me-location", () => { "/c/me/bots#owned", "/c/me/", "/c/me", + "/c/me/%E0%A4%A", ])("rejects %s", (pathname) => { expect(isRememberableMeLocation(pathname)).toBe(false) }) @@ -57,6 +58,12 @@ describe("last-me-location", () => { expect(getLastMeLeaf()).toBe("bots") }) + it("retains a valid destination when decoding a damaged URI fails", () => { + setLastMeLocation("/c/me/bots") + expect(() => setLastMeLocation("/c/me/%")).not.toThrow() + expect(getLastMeLeaf()).toBe("bots") + }) + it("falls back to friends for missing or dirty memory", () => { expect(pickMeLandingLocation(null)).toBe("/c/me/friends") expect(pickMeLandingLocation("dm_1/messages")).toBe("/c/me/friends") diff --git a/src/web/src/test/e2e-ui/04-dm.spec.ts b/src/web/src/test/e2e-ui/04-dm.spec.ts index 1e6520d47..a76dd8d5a 100644 --- a/src/web/src/test/e2e-ui/04-dm.spec.ts +++ b/src/web/src/test/e2e-ui/04-dm.spec.ts @@ -79,7 +79,7 @@ test.describe.serial("direct messages", () => { } }) - test("a canonical DM skips authority and keeps known chrome while read-state and messages load", async ({ asUser }) => { + test("a canonical DM keeps its title while history permission gates body and composer", async ({ asUser }) => { const dmId = await seedDm("alice", userId("bob")) const body = `held DM message ${Date.now()}` const messageId = await seedDmMessage("bob", dmId, body) @@ -100,11 +100,14 @@ test.describe.serial("direct messages", () => { }) let releaseRead!: () => void + let readStarted!: () => void let readFinished!: () => void const readGate = new Promise((resolve) => { releaseRead = resolve }) + const readRequest = new Promise((resolve) => { readStarted = resolve }) const readSettled = new Promise((resolve) => { readFinished = resolve }) const readPattern = `**/api/community/channels/${dmId}/read-state` await alice.page.route(readPattern, async (route) => { + readStarted() try { await readGate await route.continue() @@ -146,7 +149,10 @@ test.describe.serial("direct messages", () => { const dmTitle = alice.page.getByTestId(tid.dmHeaderTitle) await expect(dmHeader).toHaveCount(1, { timeout: 20_000 }) await expect(dmTitle).toContainText(userName("bob")) - await expect(alice.page.getByTestId(tid.composerInput)).toBeVisible() + await readRequest + await expect(alice.page.locator('[data-onboarding-target="dm-composer"] [data-slot="skeleton"]').first()).toBeVisible() + await expect(alice.page.getByTestId(tid.composerInput)).toHaveCount(0) + await expect(alice.page.getByTestId(tid.message(messageId))).toHaveCount(0) await expect(alice.page.getByTestId(tid.messageScroller).locator('[data-slot="skeleton"]')).not.toHaveCount(0) releaseRead() @@ -162,6 +168,7 @@ test.describe.serial("direct messages", () => { releaseMessages() await messagesSettled + await expect(alice.page.getByTestId(tid.composerInput)).toBeVisible() await expect(alice.page.getByTestId(tid.message(messageId))).toHaveCount(1) await expect(alice.page.getByText(body, { exact: false }).first()).toBeVisible({ timeout: 20_000 }) } finally { diff --git a/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts b/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts index caa6754e0..9e3a36195 100644 --- a/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts +++ b/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts @@ -400,13 +400,11 @@ test("server switching exposes one target-scoped cold checkpoint and skips it wh [channelA, channelB], ) const navigationEvents = await historyEvents(page) - expect(navigationEvents).toHaveLength(2) + expect(navigationEvents).toHaveLength(1) expect(navigationEvents[0]).toEqual({ kind: "pushState", - pathname: `/c/channels/${serverC}`, + pathname: `/c/channels/${serverC}/${channelC}`, }) - expect(navigationEvents[1]).toMatchObject({ kind: "replaceState" }) - expect(navigationEvents[1]?.pathname.startsWith(`/c/channels/${serverC}/`)).toBe(true) } finally { targetRsc.stop() } diff --git a/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts b/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts index 501e93c9b..31f7f19d5 100644 --- a/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts +++ b/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts @@ -146,7 +146,9 @@ async function clearQueryPersistence(page: Page) { async function expectCacheFirstReload( page: Page, + assertUnqualifiedShell: () => Promise, assertCachedContent: () => Promise, + assertReconciledContent: () => Promise, ): Promise { await page.addInitScript((loadingSelectors) => { const state = { @@ -182,14 +184,25 @@ async function expectCacheFirstReload( let releaseReads!: () => void const readsGate = new Promise((resolve) => { releaseReads = resolve }) + let releaseMessages!: () => void + const messagesGate = new Promise((resolve) => { releaseMessages = resolve }) let heldReads = 0 + let heldMessages = 0 + let forwardedMessages = 0 await page.route("**/api/community/**", async (route) => { if (route.request().method() !== "GET") { await route.continue() return } - heldReads += 1 - await readsGate + const path = new URL(route.request().url()).pathname + if (/\/(messages|pins|threads)(\/|$)/.test(path)) { + heldMessages += 1 + await messagesGate + forwardedMessages += 1 + } else { + heldReads += 1 + await readsGate + } await route.continue().catch(() => {}) }) let wsAttempts = 0 @@ -200,7 +213,7 @@ async function expectCacheFirstReload( try { await page.reload({ waitUntil: "commit" }) - await assertCachedContent() + await assertUnqualifiedShell() await expect.poll(() => heldReads).toBeGreaterThan(0) await expect.poll(() => wsAttempts).toBeGreaterThan(0) const readCapture = () => page.evaluate((): ReloadCapture | null => { @@ -236,14 +249,21 @@ async function expectCacheFirstReload( expect(capture.marks.complete).toBeGreaterThanOrEqual(capture.marks.start) expect(capture.marks.cached).toBeGreaterThanOrEqual(capture.marks.complete) expect(capture.marks.stable).toBeGreaterThanOrEqual(capture.marks.cached) + releaseReads() + await assertCachedContent() + await expect.poll(() => heldMessages).toBeGreaterThan(0) + expect(forwardedMessages).toBe(0) + releaseMessages() + await assertReconciledContent() return capture } finally { releaseReads() + releaseMessages() await page.unroute("**/api/community/**") } } -test("a warm channel reload replaces regional skeletons with cached shell and messages", async ({ asUser }) => { +test("a warm channel reload qualifies its cached shell before showing persisted messages", async ({ asUser }) => { test.setTimeout(120_000) const suffix = Date.now().toString(36) const serverId = await seedServer("alice", `Warm channel ${suffix}`) @@ -263,11 +283,21 @@ test("a warm channel reload replaces regional skeletons with cached shell and me await expectCacheFirstReload(page, async () => { await expect(page.getByTestId(tid.serverIcon(serverId))).toBeVisible({ timeout: 10_000 }) await expect(page.getByTestId(tid.channelRow(channelId))).toBeVisible() - await expect(page.getByText(body, { exact: false }).first()).toBeVisible() + const main = page.locator('[data-community-conversation-subtype="text"]') + await expect(main).toBeVisible() + await expect(main.locator("[data-message-list-skeleton]")).toBeVisible() + await expect(page.getByTestId(tid.composerInput)).toHaveCount(0) + await expect(page.getByTestId(tid.message(messageId))).toHaveCount(0) + }, async () => { + await expect(page.getByTestId(tid.message(messageId))).toBeVisible({ timeout: 20_000 }) + await expect(page.getByTestId(tid.message(messageId)).getByText(body, { exact: false })).toBeVisible() + }, async () => { + await expect(page.getByTestId(tid.message(messageId))).toBeVisible() + await expect(page.getByTestId(tid.composerInput)).toBeVisible() }) }) -test("a warm DM reload replaces regional skeletons with cached identity and messages", async ({ asUser }) => { +test("a warm DM reload qualifies cached identity and history before showing persisted messages", async ({ asUser }) => { test.setTimeout(120_000) const dmId = await seedDm("alice", userId("bob")) const body = `cached dm ${Date.now()}` @@ -286,11 +316,21 @@ test("a warm DM reload replaces regional skeletons with cached identity and mess await expectCacheFirstReload(page, async () => { await expect(page.getByTestId(tid.dmRow(dmId))).toBeVisible({ timeout: 10_000 }) - await expect(page.getByText(body, { exact: false }).first()).toBeVisible() + await expect(page.getByRole("main", { name: "Loading direct message" })).toBeVisible() + await expect(page.getByTestId(tid.messageScroller).locator("[data-message-list-skeleton]")).toBeVisible() + await expect(page.getByTestId(tid.composerInput)).toHaveCount(0) + await expect(page.getByTestId(tid.message(messageId))).toHaveCount(0) + }, async () => { + await expect(page.getByTestId(tid.dmHeader)).toBeVisible({ timeout: 20_000 }) + await expect(page.getByTestId(tid.message(messageId))).toBeVisible() + await expect(page.getByTestId(tid.message(messageId)).getByText(body, { exact: false })).toBeVisible() + }, async () => { + await expect(page.getByTestId(tid.message(messageId))).toBeVisible() + await expect(page.getByTestId(tid.composerInput)).toBeVisible() }) }) -test("a warm desktop split reload replaces regional skeletons in both cached panes", async ({ asUser }) => { +test("a warm desktop thread reload qualifies its type before restoring cached split panes", async ({ asUser }) => { test.setTimeout(120_000) const suffix = Date.now().toString(36) const serverId = await seedServer("alice", `Warm split ${suffix}`) @@ -315,13 +355,33 @@ test("a warm desktop split reload replaces regional skeletons in both cached pan ]) await expectCacheFirstReload(page, async () => { + await expect(page.getByTestId(tid.serverIcon(serverId))).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId(tid.channelRow(channelId))).toBeVisible() + await expect(page.getByTestId(tid.threadSplit)).toHaveAttribute("data-community-conversation-subtype", "thread") + await expect(page.getByTestId(tid.threadSplitPanel).locator("[data-message-list-skeleton]")).toBeVisible() + await expect(page.getByTestId(tid.composerInput)).toHaveCount(0) + await expect(page.getByTestId(tid.message(openerId))).toHaveCount(0) + await expect(page.getByTestId(tid.message(threadMessageId))).toHaveCount(0) + }, async () => { await expect(page.getByTestId(tid.threadSplit)).toHaveAttribute("data-layout", "split", { timeout: 10_000, }) + await expect(page.getByTestId(tid.threadSplitParent).getByTestId(tid.message(openerId))) + .toBeVisible() + await expect(page.getByTestId(tid.threadSplitParent).getByText(parentBody, { exact: false })) + .toBeVisible() + await expect(page.getByTestId(tid.threadSplitPanel).getByTestId(tid.message(threadMessageId))) + .toBeVisible() + await expect(page.getByTestId(tid.threadSplitPanel).getByText(threadBody, { exact: false })) + .toBeVisible() + }, async () => { + await expect(page.getByTestId(tid.threadSplit)).toHaveAttribute("data-layout", "split") await expect(page.getByTestId(tid.threadSplitParent).getByText(parentBody, { exact: false })) .toBeVisible() await expect(page.getByTestId(tid.threadSplitPanel).getByText(threadBody, { exact: false })) .toBeVisible() + await expect(page.getByTestId(tid.threadSplitParent).getByTestId(tid.composerInput)).toBeVisible() + await expect(page.getByTestId(tid.threadSplitPanel).getByTestId(tid.composerInput)).toBeVisible() }) }) From 0983e5d98eda01198cdcaa86e855074a556353b3 Mon Sep 17 00:00:00 2001 From: Gener <39689863+GenerQAQ@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:28:57 +0800 Subject: [PATCH 3/3] fix(web): qualify fresh forum sidebar channel targets --- .../use-forum-sidebar-threads.dom.test.ts | 63 +++++++++++++++++++ src/web/src/lib/community-db/sync.ts | 1 + .../e2e-ui/17-forum-sidebar-stage-b.spec.ts | 37 ++++++++--- ...6-server-switch-pending-checkpoint.spec.ts | 20 +++++- ...mmunity-cache-first-warm-reload.journey.ts | 2 +- 5 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/web/src/hooks/community/use-forum-sidebar-threads.dom.test.ts b/src/web/src/hooks/community/use-forum-sidebar-threads.dom.test.ts index 15fad4559..b87a72612 100644 --- a/src/web/src/hooks/community/use-forum-sidebar-threads.dom.test.ts +++ b/src/web/src/hooks/community/use-forum-sidebar-threads.dom.test.ts @@ -18,6 +18,9 @@ import { setCanonicalCommunityChannelMembership, } from "@/lib/community-db/sync" import { useCommunityWsStore } from "@/stores/community/ws" +import { useChannelMetadata } from "./use-channel-metadata" +import { useForumOpenerHint } from "./use-forum-opener-hint" +import { isChannelMetadataTokenCurrent } from "./channel-metadata" import { communityKeys } from "@/lib/query-keys" import { deriveForumSidebarProjection, @@ -178,6 +181,61 @@ function publish(queryClient: QueryClient, data = envelope()) { } describe("forum sidebar canonical projection", () => { + it("hands a fresh sidebar child and opener to the shared qualified route without another GET", async () => { + useCommunityWsStore.getState().activateProfileAccount("viewer") + const { queryClient, wrapper } = await setup() + apiFetchMock.mockResolvedValueOnce(envelope()) + let retainId: string | null = null + const sidebar = renderHook(() => useForumSidebarThreads("server-1", retainId), { wrapper }) + await waitFor(() => expect(sidebar.result.current.threads[0]?.title).toBe("Canonical title")) + let releaseRetained!: (value: SidebarThreadEnvelope) => void + const retained = new Promise((resolve) => { releaseRetained = resolve }) + apiFetchMock.mockReturnValue(retained) + retainId = "post-1" + sidebar.rerender() + const route = renderHook(() => { + const metadata = useChannelMetadata("server-1", "post-1") + const opener = useForumOpenerHint("server-1", metadata.data?.parentMessageId, metadata.isVerified) + return { metadata, opener } + }, { wrapper }) + try { + expect(route.result.current.metadata.isVerified).toBe(true) + expect(route.result.current.opener.data?.content).toBe("Canonical title") + await waitFor(() => expect(apiFetchMock.mock.calls.some(([url]) => new URL(url, "http://localhost").searchParams.get("retainId") === "post-1")).toBe(true)) + expect(apiFetchMock.mock.calls.some(([url]) => url === "/api/community/channels/post-1")).toBe(false) + expect(isChannelMetadataTokenCurrent(queryClient.getQueryData<{ verification: Parameters[0] }>(communityKeys.channelMeta("server-1", "post-1"))!.verification)).toBe(true) + } finally { + route.unmount() + sidebar.unmount() + await act(async () => releaseRetained(envelope())) + await invalidateForumSidebarBaseExact(queryClient, "server-1") + } + }) + + it.each(["account", "access"] as const)("cannot qualify a sidebar response from before %s changed", async (race) => { + useCommunityWsStore.getState().activateProfileAccount("viewer") + const { queryClient } = await setup() + const token = captureCommunityLiveSnapshotToken(queryClient) + if (race === "account") useCommunityWsStore.getState().activateProfileAccount("other") + else useCommunityWsStore.getState().revokeChannelAccess("server-1", "post-1") + expect(() => publishCommunityForumSidebar(queryClient, { + serverId: "server-1", channels: envelope().channels, openers: envelope().included.parentMessages, + proof: { token, signal: undefined }, + })).toThrow() + expect(queryClient.getQueryData(communityKeys.channelMeta("server-1", "post-1"))).toBeUndefined() + expect(getCanonicalCommunityChannels(queryClient).some(({ id }) => id === "post-1")).toBe(false) + }) + + it("does not qualify an archived child carried in a sidebar response", async () => { + useCommunityWsStore.getState().activateProfileAccount("viewer") + const { queryClient, registry } = await setup() + const response = envelope() + response.channels[0]!.archived = true + publish(queryClient, response) + expect(registry.collections.channels.get("post-1")?.archived).toBe(true) + expect(queryClient.getQueryData(communityKeys.channelMeta("server-1", "post-1"))).toBeUndefined() + }) + it("classifies retained route candidates and bounded active extras", () => { expect(resolveForumSidebarRouteCandidate(null, ["forum-1"], true)).toBeNull() expect(resolveForumSidebarRouteCandidate("forum-1", ["forum-1"], true)).toBeNull() @@ -883,6 +941,7 @@ describe("forum sidebar canonical projection", () => { expect(restored).toBeDefined() const restoredClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) hydrate(restoredClient, restored!.clientState) + useCommunityWsStore.getState().activateProfileAccount("viewer") const restoredRegistry = createCommunityDbRegistry(restoredClient, "viewer") restoredRegistry.captureRestoredCollections() await restoredRegistry.preload() @@ -904,6 +963,10 @@ describe("forum sidebar canonical projection", () => { ) await waitFor(() => expect(rendered.result.current.threads[0]?.id).toBe("post-1")) expect(rendered.result.current.fetchStatus).toBe("fetching") + const route = renderHook(() => useChannelMetadata("server-1", "post-1"), { wrapper }) + expect(route.result.current.isVerified).toBe(false) + expect(route.result.current.canonical?.type).toBe("thread") + route.unmount() rendered.unmount() await invalidateForumSidebarBaseExact(restoredClient, "server-1") }) diff --git a/src/web/src/lib/community-db/sync.ts b/src/web/src/lib/community-db/sync.ts index 58d19185d..1241e7f19 100644 --- a/src/web/src/lib/community-db/sync.ts +++ b/src/web/src/lib/community-db/sync.ts @@ -2225,6 +2225,7 @@ export function publishCommunityForumSidebar( ) } } + qualifyCanonicalChannelMetadata(registry, channels.map((channel) => channel.id), publication.proof.token) }) return "published" as const }) diff --git a/src/web/src/test/e2e-ui/17-forum-sidebar-stage-b.spec.ts b/src/web/src/test/e2e-ui/17-forum-sidebar-stage-b.spec.ts index d2b2f54fa..40620de4b 100644 --- a/src/web/src/test/e2e-ui/17-forum-sidebar-stage-b.spec.ts +++ b/src/web/src/test/e2e-ui/17-forum-sidebar-stage-b.spec.ts @@ -454,15 +454,34 @@ test.describe.serial("forum sidebar Stage B request shape", () => { await route.continue() }) const clickStartedAt = Date.now() - await page.getByTestId(tid.forumSidebarThread(threadId)).click() - await expect.poll(() => new URL(page.url()).pathname).toBe( - `/c/channels/${serverId}/${threadId}`, - ) - await retainedRequestStarted - await expect(childPanel(page).getByRole("heading", { name: forumTitle })) - .toBeVisible({ timeout: 1_000 }) - const warmVisibleMs = Date.now() - clickStartedAt - releaseRetained() + let warmVisibleMs: number + try { + await page.getByTestId(tid.forumSidebarThread(threadId)).click() + await expect.poll(() => new URL(page.url()).pathname).toBe( + `/c/channels/${serverId}/${threadId}`, + ) + await retainedRequestStarted + await expect(childPanel(page).getByRole("banner").getByText(forumTitle, { exact: true })) + .toBeVisible({ timeout: 1_000 }) + await expect(childPanel(page).locator(`[data-channel-id="${threadId}"]`)) + .toBeVisible({ timeout: 1_000 }) + await expect(childPanel(page).getByTestId(tid.composerInput)) + .toBeVisible({ timeout: 1_000 }) + warmVisibleMs = Date.now() - clickStartedAt + expect(requests.filter((url) => isExactChannelRequest(url, threadId))).toHaveLength(0) + await expect.poll(() => successfulResponses.filter((url) => + new URL(url).pathname === `/api/community/channels/${threadId}/read-state` + ).length).toBe(1) + await expect.poll(() => successfulResponses.filter((url) => + isChannelMessagesRequest(url, threadId) + ).length).toBe(1) + await expect(childPanel(page).getByRole("heading", { name: forumTitle })).toBeVisible() + await expect(childPanel(page).getByText("post body", { exact: true })).toBeVisible() + expect(successfulResponses.filter((url) => isSidebarRequest(url, serverId) + && new URL(url).searchParams.get("retainId") === threadId)).toHaveLength(0) + } finally { + releaseRetained() + } await expect.poll(() => successfulResponses.filter((url) => ( isSidebarRequest(url, serverId) && new URL(url).searchParams.get("retainId") === threadId diff --git a/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts b/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts index 9e3a36195..044f85167 100644 --- a/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts +++ b/src/web/src/test/e2e-ui/36-server-switch-pending-checkpoint.spec.ts @@ -361,6 +361,17 @@ test("server switching exposes one target-scoped cold checkpoint and skips it wh await expectDesktopServerDetail(page, serverC) await expect(page.getByTestId(tid.channelSidebarPending(serverC))).toHaveCount(0) await expect(page.getByTestId(tid.channelRow(channelC))).toBeVisible({ timeout: 30_000 }) + const firstCMain = page.locator('[data-slot="community-conversation-surface"]') + await expect(firstCMain).toBeVisible({ timeout: 30_000 }) + await expect(firstCMain.getByTestId(tid.composerInput)).toBeVisible() + const rememberedC = await firstCMain.getAttribute("data-channel-id") + expect(rememberedC).not.toBeNull() + const rememberedCHref = `/c/channels/${serverC}/${encodeURIComponent(rememberedC!)}` + await expect.poll(() => new URL(page.url()).pathname).toBe(rememberedCHref) + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), + `community:lastChannel:${serverC}`)).toBe(rememberedC) + const rememberedCHeading = await firstCMain.getByRole("heading").innerText() + await expect(firstCMain.getByRole("heading", { name: rememberedCHeading, exact: true })).toBeVisible() const coldFrames = await sidebarFrames(page) const coldPendingFrames = coldFrames.filter((frame) => frame.pendingServer === serverC) expect(coldPendingFrames.length).toBeGreaterThan(0) @@ -393,6 +404,13 @@ test("server switching exposes one target-scoped cold checkpoint and skips it wh expect(targetRsc.requests()).toHaveLength(1) await expectDesktopServerDetail(page, serverC) await expect(page.getByTestId(tid.channelRow(channelC))).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => new URL(page.url()).pathname).toBe(rememberedCHref) + const restoredCMain = page.locator('[data-slot="community-conversation-surface"]') + await expect(restoredCMain).toHaveAttribute("data-channel-id", rememberedC!) + await expect(restoredCMain.getByRole("heading", { name: rememberedCHeading, exact: true })).toBeVisible() + await expect(restoredCMain.getByTestId(tid.composerInput)).toBeVisible() + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), + `community:lastChannel:${serverC}`)).toBe(rememberedC) expectAtomicTargetFrames( await sidebarFrames(page), `server:${serverC}`, @@ -403,7 +421,7 @@ test("server switching exposes one target-scoped cold checkpoint and skips it wh expect(navigationEvents).toHaveLength(1) expect(navigationEvents[0]).toEqual({ kind: "pushState", - pathname: `/c/channels/${serverC}/${channelC}`, + pathname: rememberedCHref, }) } finally { targetRsc.stop() diff --git a/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts b/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts index 31f7f19d5..a3228aeb6 100644 --- a/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts +++ b/src/web/src/test/e2e-ui/community-cache-first-warm-reload.journey.ts @@ -317,7 +317,7 @@ test("a warm DM reload qualifies cached identity and history before showing pers await expectCacheFirstReload(page, async () => { await expect(page.getByTestId(tid.dmRow(dmId))).toBeVisible({ timeout: 10_000 }) await expect(page.getByRole("main", { name: "Loading direct message" })).toBeVisible() - await expect(page.getByTestId(tid.messageScroller).locator("[data-message-list-skeleton]")).toBeVisible() + await expect(page.getByTestId(tid.messageScroller).locator('[data-slot="skeleton"]').first()).toBeVisible() await expect(page.getByTestId(tid.composerInput)).toHaveCount(0) await expect(page.getByTestId(tid.message(messageId))).toHaveCount(0) }, async () => {