Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions desktop/src/app/routes/ChannelRouteScreen.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
getValidatedRouteThreadRootId,
hasValidRouteThreadIntent,
isRouteEventForChannel,
} from "./ChannelRouteScreen.tsx";

function event(id, tags = [["h", "channel"]]) {
return {
id,
pubkey: "author",
created_at: 1,
kind: 9,
tags,
content: "hello",
sig: "signature",
};
}

test("a top-level route only accepts its own id as thread root", () => {
const target = event("target");
assert.equal(getValidatedRouteThreadRootId(target, "target"), "target");
assert.equal(getValidatedRouteThreadRootId(target, "unrelated"), null);
assert.equal(getValidatedRouteThreadRootId(target, null), null);
});

test("a reply route derives its containing root", () => {
const target = event("reply", [
["h", "channel"],
["e", "root", "", "root"],
["e", "root", "", "reply"],
]);
assert.equal(getValidatedRouteThreadRootId(target, null), "root");
assert.equal(getValidatedRouteThreadRootId(target, "root"), "root");
assert.equal(getValidatedRouteThreadRootId(target, "unrelated-root"), null);
assert.equal(hasValidRouteThreadIntent(target, null), true);
assert.equal(hasValidRouteThreadIntent(target, "root"), true);
assert.equal(hasValidRouteThreadIntent(target, "unrelated-root"), false);
});

test("route events must belong to the routed channel", () => {
assert.equal(isRouteEventForChannel(event("target"), "channel"), true);
assert.equal(isRouteEventForChannel(event("target"), "other-channel"), false);
assert.equal(isRouteEventForChannel(event("target", []), "channel"), false);
});
72 changes: 58 additions & 14 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,47 @@ function getReplyParentId(event: RelayEvent): string | null {
return getThreadReference(event.tags).parentId;
}

export function isRouteEventForChannel(
event: RelayEvent,
channelId: string,
): boolean {
return event.tags.some((tag) => tag[0] === "h" && tag[1] === channelId);
}

export function getValidatedRouteThreadRootId(
targetEvent: RelayEvent,
targetThreadRootId: string | null,
): string | null {
const targetThreadRef = getThreadReference(targetEvent.tags);
if (getReplyParentId(targetEvent) === null) {
return targetThreadRootId === targetEvent.id ? targetThreadRootId : null;
}
const derivedRootId = targetThreadRef.rootId ?? null;
return targetThreadRootId === null || targetThreadRootId === derivedRootId
? derivedRootId
: null;
}

export function hasValidRouteThreadIntent(
targetEvent: RelayEvent,
targetThreadRootId: string | null,
): boolean {
return (
getReplyParentId(targetEvent) === null ||
targetThreadRootId === null ||
getValidatedRouteThreadRootId(targetEvent, targetThreadRootId) !== null
);
}

async function fetchRouteTargetEvents(
channelId: string,
eventIds: string[],
targetMessageId: string | null,
targetThreadRootId: string | null,
): Promise<RelayEvent[]> {
const eventsById = new Map<string, RelayEvent>();
const addEvent = (event: RelayEvent | null) => {
if (event) {
if (event && isRouteEventForChannel(event, channelId)) {
eventsById.set(event.id, event);
}
};
Expand All @@ -66,12 +99,17 @@ async function fetchRouteTargetEvents(
const targetEvent = targetMessageId
? (eventsById.get(targetMessageId) ?? null)
: null;
if (!targetEvent) {
if (
!targetEvent ||
!hasValidRouteThreadIntent(targetEvent, targetThreadRootId)
) {
return [...eventsById.values()];
}

const targetThreadRef = getThreadReference(targetEvent.tags);
const threadRootId = targetThreadRootId ?? targetThreadRef.rootId ?? null;
const threadRootId = getValidatedRouteThreadRootId(
targetEvent,
targetThreadRootId,
);
if (threadRootId && !eventsById.has(threadRootId)) {
addEvent(await fetchRouteEvent(threadRootId));
}
Expand All @@ -85,7 +123,7 @@ async function fetchRouteTargetEvents(
) {
const parentEvent =
eventsById.get(parentId) ?? (await fetchRouteEvent(parentId));
if (!parentEvent) {
if (!parentEvent || !isRouteEventForChannel(parentEvent, channelId)) {
break;
}

Expand Down Expand Up @@ -130,7 +168,9 @@ export function ChannelRouteScreen({
RelayEvent[]
>(() => {
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
return cachedTarget && isRouteEventForChannel(cachedTarget, channelId)
? [cachedTarget]
: [];
});

// Reset spliced target events when the channel context changes (channel
Expand Down Expand Up @@ -166,22 +206,25 @@ export function ChannelRouteScreen({
}

const cachedTarget = getCachedSearchHitEvent(targetMessageId);
if (cachedTarget) {
if (cachedTarget && isRouteEventForChannel(cachedTarget, channelId)) {
setTargetMessageEvents((currentEvents) =>
currentEvents.some((event) => event.id === cachedTarget.id)
? currentEvents
: [...currentEvents, cachedTarget],
);
}

const eventIds = [
targetMessageId,
targetThreadRootId && targetThreadRootId !== targetMessageId
? targetThreadRootId
: null,
].filter((eventId): eventId is string => eventId !== null);
// The selected message is authoritative. Load it first so the helper can
// validate any supplied thread relationship before fetching another event.
// A thread-only route has no selected message to validate against.
const eventIds = targetMessageId
? [targetMessageId]
: targetThreadRootId
? [targetThreadRootId]
: [];

void fetchRouteTargetEvents(
channelId,
eventIds,
targetMessageId,
targetThreadRootId,
Expand All @@ -200,7 +243,7 @@ export function ChannelRouteScreen({
return () => {
isCancelled = true;
};
}, [selectedPostId, targetMessageId, targetThreadRootId]);
}, [channelId, selectedPostId, targetMessageId, targetThreadRootId]);

if (
!activeChannel &&
Expand Down Expand Up @@ -234,6 +277,7 @@ export function ChannelRouteScreen({
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
targetThreadRootId={targetThreadRootId}
/>
);
}
6 changes: 3 additions & 3 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState";
import type { ChannelScreenProps } from "./ChannelScreen.types";
import { GuardedChannelPane } from "./GuardedChannelPane";
import { useNavigationGuard } from "./useNavigationGuard";

const EMPTY_RELAY_EVENTS: RelayEvent[] = [];
export function ChannelScreen({
activeChannel,
Expand All @@ -101,6 +102,7 @@ export function ChannelScreen({
targetForumReplyId,
targetMessageEvents,
targetMessageId,
...routeTargets
}: ChannelScreenProps) {
const queryClient = useQueryClient();
const { goHome } = useAppNavigation();
Expand Down Expand Up @@ -632,9 +634,6 @@ export function ChannelScreen({
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
},
// A persisted head only counts as hydrated when it has rows to paint
// (channelHeadCache.ts), so this bypass never settles onto an empty
// placeholder while the authoritative refresh is still in flight.
hasSettledThisChannel ||
(activeChannelId !== null &&
hasPersistedHydratedChannel(queryClient, activeChannelId)),
Expand Down Expand Up @@ -672,6 +671,7 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
targetMessageId,
targetThreadRootId: routeTargets.targetThreadRootId,
timelineMessages,
});
useThreadTargetSync({
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ export type ChannelScreenProps = {
targetForumReplyId: string | null;
targetMessageEvents: RelayEvent[];
targetMessageId: string | null;
/**
* Thread root requested by the navigation source (`?threadRootId`, or the
* `?thread` panel param on deep links). Deciding input for top-level route
* targets: present → open the thread panel at that root; absent → the
* target is shown in the main timeline only.
*/
targetThreadRootId: string | null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";

const dom = new JSDOM("<!doctype html><html><body></body></html>");
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: dom.window.navigator,
});
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

const React = await import("react");
const { act } = React;
const { createRoot } = await import("react-dom/client");
const { useChannelRouteTarget } = await import("./useChannelRouteTarget.ts");

const target = {
id: "target",
author: "alice",
body: "hello",
createdAt: 1,
depth: 0,
parentId: null,
rootId: null,
tags: [],
time: "now",
};

function Harness({ calls, threadRootId }) {
useChannelRouteTarget({
activeChannel: { id: "channel", channelType: "stream" },
activeChannelId: "channel",
closeAgentSession: () => calls.push("close-agent"),
requireThreadEditResolution: () => true,
setEditTargetId: () => {},
setExpandedThreadReplyIds: () => {},
setOpenThreadHeadId: (id) => calls.push(`open:${id}`),
setProfilePanelPubkey: () => {},
setThreadReplyTargetId: () => {},
setThreadScrollTargetId: () => {},
targetMessageId: "target",
targetThreadRootId: threadRootId,
timelineMessages: [target],
});
return null;
}

test("the same top-level target can advance from timeline-only to open-thread", async () => {
const calls = [];
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(Harness, { calls, threadRootId: null }));
});
assert.deepEqual(calls, []);

await act(async () => {
root.render(
React.createElement(Harness, { calls, threadRootId: "target" }),
);
});
assert.deepEqual(calls, ["close-agent", "open:target"]);
await act(async () => root.unmount());
});
Loading
Loading