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 () => {