Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/web/src/app/c/me/[dmId]/page.dismissal.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const {
vi.mock("next/navigation", () => ({
useParams: () => ({ dmId: "dm_1" }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/c/me/dm_1",
useRouter: () => ({ replace: vi.fn() }),
}))
vi.mock("sonner", () => ({ toast: vi.fn() }))
vi.mock("@/hooks/use-mobile", () => ({ useBreakpoint: () => "desktop" }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ForumChannelSurface } from "./forum-channel-surface"
import { MessageList } from "../messages/message-list"
import { useChannelMemberViewModel } from "../members/channel-member-view-model"
import { useChannelMessageFeed } from "@/hooks/community/use-channel-message-feed"
import type { ConversationNavigationTarget } from "@/lib/community/conversation-navigation-proof"

const {
mockRouteModel,
Expand All @@ -17,6 +18,7 @@ const {
mockHeaderServerNavigate,
mockHeaderParentNavigate,
mockOpenerGate,
mockForumOpener,
mockSearchParams,
mockSplitMode,
mockSplitParentSurface,
Expand All @@ -34,13 +36,14 @@ const {
mockHeaderServerNavigate: { current: undefined as undefined | (() => void) },
mockHeaderParentNavigate: { current: undefined as undefined | (() => void) },
mockOpenerGate: vi.fn(() => null),
mockForumOpener: { data: null as null | { content: string }, isLoading: false, isError: false, error: null as Error | null, isFetching: false, refetch: vi.fn(() => Promise.resolve()) },
mockSearchParams: { value: "msg=m_target&keep=1" },
mockSplitMode: { value: "full" as "split" | "full" },
mockSplitParentSurface: vi.fn(() => null),
mockCommitLastCommunityRoute: vi.fn(),
mockSetLastChannel: vi.fn(),
mockClearLastChannel: vi.fn(),
mockNavigationGate: { allowed: true },
mockNavigationGate: { allowed: true, target: null as ConversationNavigationTarget | null },
mockCurrentChannelId: { value: "channel_1" as string | null },
mockCanManageServer: vi.fn((role?: string | null) => role === "owner" || role === "admin"),
mockDismissConversation: vi.fn(),
Expand All @@ -64,6 +67,9 @@ const {
isChild: false,
isForumPostChild: false,
isNotifyUnit: false,
serverError: false,
retryingServer: false,
retryServer: vi.fn(),
metadataError: false,
retryingMetadata: false,
retryMetadata: vi.fn(),
Expand Down Expand Up @@ -91,7 +97,7 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(mockSearchParams.value),
}))
vi.mock("@/lib/community/conversation-navigation-proof", async (importOriginal) => ({ ...await importOriginal<typeof import("@/lib/community/conversation-navigation-proof")>(),
useConversationNavigationGate: () => ({ required: false, allowed: mockNavigationGate.allowed }),
useConversationNavigationGate: () => ({ required: false, allowed: mockNavigationGate.allowed, target: mockNavigationGate.target }),
}))
vi.mock("sonner", () => ({ toast: vi.fn() }))
vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn(), toastApiError: vi.fn() }))
Expand Down Expand Up @@ -230,7 +236,7 @@ vi.mock("@/components/community/channels/thread-split-view", () => ({
),
}))
vi.mock("@/hooks/community/use-forum-opener-hint", () => ({
useForumOpenerHint: () => ({ data: null, isLoading: false }),
useForumOpenerHint: () => mockForumOpener,
}))
vi.mock("@/hooks/community/use-server-members", () => ({
useServerMembers: () => ({
Expand Down Expand Up @@ -349,6 +355,8 @@ describe("ChannelRoute message surface ownership", () => {
vi.useFakeTimers()
mockedMessageList.mockClear()
mockOpenerGate.mockClear()
Object.assign(mockForumOpener, { data: null, isLoading: false, isError: false, error: null, isFetching: false })
mockForumOpener.refetch.mockClear()
mockSearchParams.value = "msg=m_target&keep=1"
mockSplitMode.value = "full"
mockSplitParentSurface.mockClear()
Expand All @@ -360,6 +368,7 @@ describe("ChannelRoute message surface ownership", () => {
mockClearLastChannel.mockClear()
mockDismissConversation.mockClear()
mockNavigationGate.allowed = true
mockNavigationGate.target = null
mockCurrentChannelId.value = "channel_1"
mockMemberViewModel.myRole = "member"
Object.assign(mockRouteModel, {
Expand All @@ -376,6 +385,8 @@ describe("ChannelRoute message surface ownership", () => {
isChild: false,
isForumPostChild: false,
isNotifyUnit: false,
serverError: false,
retryingServer: false,
metadataError: false,
retryingMetadata: false,
routeHydrated: true,
Expand Down Expand Up @@ -496,6 +507,39 @@ describe("ChannelRoute message surface ownership", () => {
expect(mockCommitLastCommunityRoute).not.toHaveBeenCalled()
})

it("shows a required cold forum opener failure, keeps Retry feedback while pending, then opens body", async () => {
configureThreadRoute()
mockRouteModel.isForumPostChild = true
Object.assign(mockForumOpener, { isError: true, error: new Error("deadline") })
let release!: () => void
mockForumOpener.refetch.mockImplementationOnce(() => new Promise<void>((resolve) => { release = resolve }))
const props = { serverParam: "server_1", channelId: "channel_1" }
const renderer = render(React.createElement(ChannelRoute, props))
expect(screen.getByRole("button", { name: "Retry" })).not.toBeDisabled()
expect(mockedUseChannelMessageFeed).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "Retry" }))
expect(mockForumOpener.refetch).toHaveBeenCalledOnce()
Object.assign(mockForumOpener, { isError: false, error: null, isFetching: true, isLoading: true })
renderer.rerender(React.createElement(ChannelRoute, props))
expect(screen.getByRole("button", { name: "Retrying…" })).toBeDisabled()
await act(async () => { release() })
Object.assign(mockForumOpener, { data: { content: "Recovered title" }, isFetching: false, isLoading: false })
mockedUseChannelMessageFeed.mockReturnValue(feed())
renderer.rerender(React.createElement(ChannelRoute, props))
expect(screen.queryByRole("alert")).toBeNull()
expect(mockedUseChannelMessageFeed).toHaveBeenCalled()
renderer.unmount()
})

it("shows a server dependency error before unknown skeleton and forwards Retry", () => {
Object.assign(mockRouteModel, { serverError: true, routeLifecycle: "terminal-error", routeHydrated: false, skeletonSubtype: "unknown" })
const renderer = render(React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" }))
fireEvent.click(screen.getByRole("button", { name: "Retry" }))
expect(mockRouteModel.retryServer).toHaveBeenCalledOnce()
expect(mockedUseChannelMessageFeed).not.toHaveBeenCalled()
renderer.unmount()
})

it("renders the terminal metadata error without opening a feed and forwards Retry", async () => {
Object.assign(mockRouteModel, {
isChild: true, routeLifecycle: "terminal-error", routeHydrated: false,
Expand Down Expand Up @@ -800,6 +844,35 @@ describe("ChannelRoute message surface ownership", () => {
vi.clearAllMocks()
})

it.each(["text", "thread"])("hands the current Marked anchor to the %s feed before mount revalidation", (kind) => {
if (kind === "thread") configureThreadRoute()
mockSearchParams.value = "keep=1"
mockedUseChannelMessageFeed.mockReturnValue(feed())
const route = () => React.createElement(ChannelRoute, { serverParam: "server_1", channelId: "channel_1" })
const renderer = render(route())
for (const anchorMessageId of ["first-marked", "second-marked", "second-marked"]) {
mockNavigationGate.allowed = false
mockNavigationGate.target = { href: "/c/channels/server_1/channel_1?seq=7",
viewerId: "viewer_1", channelId: "channel_1", serverId: "server_1", scopeKind: "channel", anchorMessageId }
renderer.rerender(route())
mockedUseChannelMessageFeed.mockClear()
mockNavigationGate.allowed = true
renderer.rerender(route())
expect(mockedUseChannelMessageFeed.mock.calls[0]?.[0].anchorMessageId).toBe(anchorMessageId)
mockNavigationGate.target = null
renderer.rerender(route())
expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBe(anchorMessageId)
}
mockNavigationGate.target = { href: "/c/channels/server_1/channel_1",
viewerId: "viewer_1", channelId: "channel_1", serverId: "server_1", scopeKind: "channel" }
renderer.rerender(route())
expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBeNull()
mockNavigationGate.target = null
renderer.rerender(route())
expect(mockedUseChannelMessageFeed.mock.lastCall?.[0].anchorMessageId).toBeNull()
expect(mockRouter.replace).not.toHaveBeenCalled()
})

it("keeps the route anchor until MessageList reports a successful jump", async () => {
let surfaceFeed = feed({ messages: [{ id: "m_unrelated" }] })
mockedUseChannelMessageFeed.mockImplementation(() => surfaceFeed)
Expand Down
56 changes: 42 additions & 14 deletions src/web/src/components/community/channels/channel-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useAtom, useCreateAtom } from "@tanstack/react-store";
import { getCommunityRuntime } from "@/stores/community/runtime"


import { useCallback, useEffect, useLayoutEffect, useMemo } from "react"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { toastApiError } from "@/lib/api/client"
import { ChannelHeaderSkeleton, type ChannelNotifLevel } from "@/components/community/channels/channel-header"
Expand Down Expand Up @@ -40,6 +40,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { useCommunityWsStore } from "@/stores/community/ws"
import { useConversationNavigationGate } from "@/lib/community/conversation-navigation-proof"
import { resolveConversationSubtype } from "@/lib/community/conversation-subtype"
import { isConversationAccessError } from "@/lib/community/conversation-read"
import { useNativeSystemNotificationConversationDismissal } from "@/hooks/community/use-native-system-notifications"

const THREAD_VIEW_PARAM = "threadView"
Expand All @@ -59,12 +60,7 @@ export function ChannelRoute({ serverParam, channelId }: {
const searchParams = useSearchParams()
const serverId = decodeURIComponent(serverParam)
const currentUser = useCurrentUser()
// Cross-channel "jump to message" target, captured ONCE at mount from `?msg=`.
// `ChannelView` is keyed by `serverId/channelId`, so a fresh jump remounts and
// re-reads this. The param is stripped from the URL right after (below) so a
// refresh/back doesn't re-trigger the jump; this frozen copy still drives the
// anchor + scroll for this mount.
const [jumpTargetId] = useAtom(useCreateAtom<string | null>((() => searchParams.get("msg"))()))
const [jumpTargetId, setJumpTargetId] = useAtom(useCreateAtom<string | null>((() => searchParams.get("msg"))()))
const queryClient = useQueryClient()
const accessEpoch = useCommunityWsStore((state) => state.accessEpoch)
const navigationGate = useConversationNavigationGate(
Expand All @@ -73,6 +69,12 @@ const [jumpTargetId] = useAtom(useCreateAtom<string | null>((() => searchParams.
channelId,
accessEpoch,
)
const navigationTarget = navigationGate.target
const currentAnchorMessageId = navigationTarget
? navigationTarget.anchorMessageId ?? null : jumpTargetId
useLayoutEffect(() => {
if (navigationTarget) setJumpTargetId(navigationTarget.anchorMessageId ?? null)
}, [navigationTarget, setJumpTargetId])
const uiHandlers = useUiHandlers()
const currentChannelId = useCurrentChannelId()
const routeModel = useChannelRouteModel(serverId, serverParam, channelId, currentUser.id)
Expand Down Expand Up @@ -108,6 +110,24 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
currentChannelMeta?.parentMessageId,
isForumPostChild && routeModel.routeHydrated && navigationGate.allowed,
)
const { isFetching: fetchingOpener, refetch: refetchOpener } = forumPostOpener
const openerScope = JSON.stringify([currentUser.id, serverId, channelId, currentChannelMeta?.parentMessageId, accessEpoch])
const [openerRetryAttempt, setOpenerRetryAttempt] = useAtom(useCreateAtom<{ scope: string } | null>(null))
const openerRetryRef = useRef<{ scope: string } | null>(null)
const retryingOpener = openerRetryAttempt?.scope === openerScope
const openerError = isForumPostChild && (isConversationAccessError(forumPostOpener.error)
|| (!forumPostOpener.data && (forumPostOpener.isError || retryingOpener)))
const retryOpener = useCallback(async () => {
if (!openerError || fetchingOpener || openerRetryRef.current?.scope === openerScope) return
const attempt = { scope: openerScope }
openerRetryRef.current = attempt
setOpenerRetryAttempt(attempt)
try { await refetchOpener({ cancelRefetch: false }) }
finally {
if (openerRetryRef.current === attempt) openerRetryRef.current = null
setOpenerRetryAttempt((current) => current === attempt ? null : current)
}
}, [fetchingOpener, refetchOpener, openerError, openerScope, setOpenerRetryAttempt])
const threadOpenerHandoff = useThreadOpenerRouteGate({
serverId,
childChannelId: channelId,
Expand Down Expand Up @@ -185,11 +205,8 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
})
}, [channelId, setChannelNotif])

// Strip `?msg=` from the URL right after mount so a refresh/back doesn't
// re-trigger the jump. The frozen `jumpTargetId` still seeds the mounted
// message controller for this mount; this only cleans the address.
useEffect(() => {
if (!jumpTargetId || searchParams.has(THREAD_OPENER_HANDOFF_PARAM)) return
if (!jumpTargetId || !searchParams.has("msg") || searchParams.has(THREAD_OPENER_HANDOFF_PARAM)) return
const search = searchParams.toString()
const routePath = channelHref(serverParam, channelId)
const href = `${routePath}${search ? `?${search}` : ""}`
Expand Down Expand Up @@ -219,7 +236,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
routeModel.routeLifecycle === "ready" &&
currentChannelId === channelId &&
routeModel.routeHydrated &&
(!isForumPostChild || !forumPostOpener.isLoading) &&
(!isForumPostChild || (!forumPostOpener.isLoading && !openerError)) &&
navigationGate.allowed
useNativeSystemNotificationConversationDismissal(currentUser.id, {
kind: "server",
Expand All @@ -240,12 +257,23 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
isForum,
structuralHint: routeModel.skeletonSubtype,
})
if (navigationGate.failed) {
return <ConversationResolutionErrorFrame retrying={false} onRetry={navigationGate.retry} />
}
if (routeModel.serverError) {
return <ConversationResolutionErrorFrame retrying={routeModel.retryingServer}
onRetry={() => { void routeModel.retryServer() }} />
}
if (routeModel.metadataError) {
return <ConversationResolutionErrorFrame
retrying={routeModel.retryingMetadata}
onRetry={() => { void routeModel.retryMetadata() }}
/>
}
if (openerError) {
return <ConversationResolutionErrorFrame retrying={retryingOpener}
onRetry={() => { void retryOpener() }} />
}
if (subtype === "unknown") {
return <ConversationResolutionPendingFrame />
}
Expand Down Expand Up @@ -335,7 +363,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
channelName={channelName}
viewer={currentUser}
canManagePins={canManageServer(myRole)}
anchorMessageId={jumpTargetId}
anchorMessageId={currentAnchorMessageId}
parentChannelId={currentChannelMeta?.parentChannelId ?? null}
parentMessageId={currentChannelMeta?.parentMessageId ?? null}
parentIsForum={isForumPostChild}
Expand Down Expand Up @@ -396,7 +424,7 @@ const [topLevelRouteOwnership, setTopLevelRouteOwnership] = useAtom(useCreateAto
channelName={channelName}
viewer={currentUser}
canManagePins={canManageServer(myRole)}
anchorMessageId={jumpTargetId}
anchorMessageId={currentAnchorMessageId}
onNavigateParent={navigateServerRoot}
notificationLevel={(channelNotif[channelId] as ChannelNotifLevel) ?? USE_SERVER_DEFAULT}
onSetNotificationLevel={setNotificationLevel}
Expand Down
Loading
Loading