Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";

const { allowMessageTargetNavigation, registerMessageTargetNavigationGuard } =
await import("./messageTargetNavigationGuard.ts");

const target = {
kind: "channel-message",
channelId: "general",
messageId: "message-a",
threadRootId: "thread-a",
};

test("all message-target navigation consults the registered boundary guard", () => {
let received;
const unregister = registerMessageTargetNavigationGuard((nextTarget) => {
received = nextTarget;
return false;
});

assert.equal(allowMessageTargetNavigation(target), false);
assert.deepEqual(received, target);
unregister();
assert.equal(allowMessageTargetNavigation(target), true);
});

test("unregistering the newer guard restores the prior live guard", () => {
const unregisterFirst = registerMessageTargetNavigationGuard(() => false);
const unregisterSecond = registerMessageTargetNavigationGuard(() => true);

assert.equal(allowMessageTargetNavigation(target), true);
unregisterSecond();
assert.equal(allowMessageTargetNavigation(target), false);
unregisterFirst();
assert.equal(allowMessageTargetNavigation(target), true);
});

test("stale cleanup cannot unregister a newer guard", () => {
const unregisterFirst = registerMessageTargetNavigationGuard(() => false);
const unregisterSecond = registerMessageTargetNavigationGuard(() => true);

unregisterFirst();
assert.equal(allowMessageTargetNavigation(target), true);
unregisterSecond();
assert.equal(allowMessageTargetNavigation(target), true);
});

test("duplicate callback registrations clean up by registration identity", () => {
const sharedGuard = () => false;
const unregisterFirst = registerMessageTargetNavigationGuard(sharedGuard);
const unregisterSecond = registerMessageTargetNavigationGuard(sharedGuard);

unregisterFirst();
assert.equal(allowMessageTargetNavigation(target), false);
unregisterSecond();
assert.equal(allowMessageTargetNavigation(target), true);
});
40 changes: 40 additions & 0 deletions desktop/src/app/navigation/messageTargetNavigationGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type MessageTargetNavigation =
| {
kind: "channel-message";
channelId: string;
messageId: string;
threadRootId: string | null;
}
| {
kind: "forum-post";
channelId: string;
postId: string;
replyId: string | null;
};

type MessageTargetNavigationGuard = (
target: MessageTargetNavigation,
) => boolean;

type GuardRegistration = {
guard: MessageTargetNavigationGuard;
};

const activeGuards: GuardRegistration[] = [];

export function allowMessageTargetNavigation(
target: MessageTargetNavigation,
): boolean {
return activeGuards.at(-1)?.guard(target) ?? true;
}

export function registerMessageTargetNavigationGuard(
guard: MessageTargetNavigationGuard,
): () => void {
const registration = { guard };
activeGuards.push(registration);
return () => {
const index = activeGuards.lastIndexOf(registration);
if (index >= 0) activeGuards.splice(index, 1);
};
}
36 changes: 30 additions & 6 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@tanstack/react-router";

import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import { allowMessageTargetNavigation } from "@/app/navigation/messageTargetNavigationGuard";
import type { SearchHit } from "@/shared/api/types";

type NavigationBehavior = {
Expand Down Expand Up @@ -256,8 +257,19 @@ export function useAppNavigation() {
thread?: string;
threadRootId?: string | null;
},
) =>
commitNavigation(
) => {
if (
options?.messageId &&
!allowMessageTargetNavigation({
kind: "channel-message",
channelId,
messageId: options.messageId,
threadRootId: options.threadRootId ?? null,
})
) {
return Promise.resolve(false);
}
return commitNavigation(
{
to: "/channels/$channelId",
params: {
Expand All @@ -282,7 +294,8 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: options?.messageId ? true : undefined,
},
),
);
},
[commitNavigation],
);

Expand All @@ -307,8 +320,18 @@ export function useAppNavigation() {
replace?: boolean;
replyId?: string;
},
) =>
commitNavigation(
) => {
if (
!allowMessageTargetNavigation({
kind: "forum-post",
channelId,
postId,
replyId: options?.replyId ?? null,
})
) {
return Promise.resolve(false);
}
return commitNavigation(
{
to: "/channels/$channelId/posts/$postId",
params: {
Expand All @@ -322,7 +345,8 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: false,
},
),
);
},
[commitNavigation],
);

Expand Down
106 changes: 81 additions & 25 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import { toast } from "sonner";
import { Hash, LogIn } from "lucide-react";
import { AnimatePresence } from "motion/react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
Expand All @@ -23,6 +24,7 @@ import {
hasOtherDmParticipant,
} from "@/features/channels/lib/dmHuddleMembers";
import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
import { isThreadReply } from "@/features/messages/lib/threading";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
Expand Down Expand Up @@ -220,11 +222,7 @@ export const ChannelPane = React.memo(function ChannelPane({
isActiveWelcomeChannel,
currentPubkey ?? null,
);
const isEditInThread =
editTarget != null &&
threadHeadMessage != null &&
(editTarget.id === threadHeadMessage.id ||
threadMessages.some((entry) => entry.message.id === editTarget.id));
const isEditInThread = editTarget?.isThreadReply === true;
const mainEditTarget = editTarget && !isEditInThread ? editTarget : null;
const threadEditTarget = editTarget && isEditInThread ? editTarget : null;
const findLastOwnEditable = React.useCallback(
Expand All @@ -247,23 +245,7 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[onEdit, currentPubkey],
);
const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(messages);
if (!target || !onEdit) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, messages, onEdit]);

const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
if (!onEdit) return false;
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
for (const entry of threadMessages) scope.push(entry.message);
const target = findLastOwnEditable(scope);
if (!target) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const timeoutState = useTimeoutState();
// A moderation DM (1:1 with the relay identity) is read-only for the member;
// only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` →
Expand Down Expand Up @@ -461,6 +443,81 @@ export const ChannelPane = React.memo(function ChannelPane({
useFocusThreadDrawer,
onCloseThread,
);
const pendingMainEditRef = React.useRef<TimelineMessage | null>(null);
const editTargetRef = React.useRef(editTarget);
editTargetRef.current = editTarget;
const pendingMainEditContextRef = React.useRef({
channelId: activeChannel?.id ?? null,
threadId: threadHeadMessage?.id ?? null,
});
const pendingMainEditContext = {
channelId: activeChannel?.id ?? null,
threadId: threadHeadMessage?.id ?? null,
};
const previousPendingContext = pendingMainEditContextRef.current;
if (
previousPendingContext.channelId !== pendingMainEditContext.channelId ||
(previousPendingContext.threadId !== null &&
pendingMainEditContext.threadId !== null &&
previousPendingContext.threadId !== pendingMainEditContext.threadId)
) {
pendingMainEditRef.current = null;
}
pendingMainEditContextRef.current = pendingMainEditContext;
const handleRoutedEdit = React.useCallback(
(message: TimelineMessage): boolean => {
const currentEditTarget = editTargetRef.current;
if (
currentEditTarget &&
currentEditTarget.id !== message.id &&
currentEditTarget.isThreadReply !== isThreadReply(message.tags ?? [])
) {
pendingMainEditRef.current = null;
toast.info("Finish or cancel your edit first.");
return false;
}
if (currentEditTarget?.id === message.id) {
pendingMainEditRef.current = null;
onEdit?.(message);
return true;
}
if (
!isThreadReply(message.tags ?? []) &&
(isSinglePanelView || useFocusThreadDrawer)
) {
pendingMainEditRef.current = message;
onCloseThread();
return true;
}
onEdit?.(message);
return Boolean(onEdit);
},
[isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer],
);
const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(
mainTimelineEntries.map((entry) => entry.message),
);
return target ? handleRoutedEdit(target) : false;
}, [findLastOwnEditable, handleRoutedEdit, mainTimelineEntries]);
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
for (const entry of threadMessages) scope.push(entry.message);
const target = findLastOwnEditable(scope);
return target ? handleRoutedEdit(target) : false;
}, [
findLastOwnEditable,
handleRoutedEdit,
threadHeadMessage,
threadMessages,
]);
React.useEffect(() => {
const pendingMainEdit = pendingMainEditRef.current;
if (!pendingMainEdit || isSinglePanelView || channelIsCovered) return;
pendingMainEditRef.current = null;
onEdit?.(pendingMainEdit);
}, [channelIsCovered, isSinglePanelView, onEdit]);
const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } =
useThreadViewModeSwitch({
activeThreadHeadId: threadHeadMessage?.id ?? null,
Expand Down Expand Up @@ -508,6 +565,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useFocusThreadDrawer ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
hasActiveEdit={threadEditTarget !== null}
key={THREAD_SURFACE_KEY}
onClose={onCloseThread}
>
Expand Down Expand Up @@ -542,7 +600,6 @@ export const ChannelPane = React.memo(function ChannelPane({
data-testid="channel-shared-header-backdrop"
/>
) : null}

{!isSinglePanelView ? (
<section
aria-label="Channel messages and composer"
Expand Down Expand Up @@ -616,7 +673,7 @@ export const ChannelPane = React.memo(function ChannelPane({
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
onDelete={onDelete}
onEdit={onEdit}
onEdit={handleRoutedEdit}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onReply={timelineReplyHandler}
Expand Down Expand Up @@ -758,7 +815,6 @@ export const ChannelPane = React.memo(function ChannelPane({
</div>
</section>
) : null}

{/*
* `AnimatePresence` keeps the focus thread drawer mounted through its exit
* animation — without it the drawer's own existence condition
Expand Down Expand Up @@ -808,7 +864,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEdit={handleRoutedEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
Expand Down
11 changes: 2 additions & 9 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type * as React from "react";
import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
import type { TimelineMessage } from "@/features/messages/types";
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { DraftMentionRef } from "@/features/messages/lib/useDrafts";
import type {
ProfilePanelTab,
ProfilePanelView,
Expand Down Expand Up @@ -37,13 +36,7 @@ export type ChannelPaneProps = {
botTypingEntries: TypingIndicatorEntry[];
channelManagementOpen?: boolean;
currentPubkey?: string;
editTarget?: {
author: string;
body: string;
id: string;
imetaMedia?: ImetaMedia[];
mentionRefs?: DraftMentionRef[];
} | null;
editTarget?: MessageComposerEditTarget | null;
fetchOlder?: () => Promise<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;
Expand Down
Loading
Loading