Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
58 changes: 58 additions & 0 deletions desktop/src/app/navigation/navigationGuard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";

const { allowNavigation, registerNavigationGuard } = await import(
"./navigationGuard.ts"
);

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

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

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

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

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

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

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

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

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

type NavigationGuard = (target: GuardedNavigation) => boolean;

type GuardRegistration = {
guard: NavigationGuard;
};

const activeGuards: GuardRegistration[] = [];

export function allowNavigation(target: GuardedNavigation): boolean {
return activeGuards.at(-1)?.guard(target) ?? true;
}

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

import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import {
allowNavigation,
type GuardedNavigation,
} from "@/app/navigation/navigationGuard";
import type { SearchHit } from "@/shared/api/types";

type NavigationBehavior = {
Expand All @@ -30,13 +34,22 @@ export function useAppNavigation() {
state?: Record<string, unknown>;
},
behavior: NavigationBehavior = {},
guardedTarget?: GuardedNavigation,
) => {
const nextLocation = router.buildLocation(next as never);

if (location.href === nextLocation.href && !behavior.force) {
return false;
}

if (
!allowNavigation(
guardedTarget ?? { kind: "route", href: nextLocation.href },
)
) {
return false;
}

await navigate({
...next,
replace: behavior.replace,
Expand Down Expand Up @@ -256,8 +269,8 @@ export function useAppNavigation() {
thread?: string;
threadRootId?: string | null;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId",
params: {
Expand All @@ -282,7 +295,16 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: options?.messageId ? true : undefined,
},
),
options?.messageId
? {
kind: "channel-message",
channelId,
messageId: options.messageId,
threadRootId: options.threadRootId ?? null,
}
: undefined,
);
},
[commitNavigation],
);

Expand All @@ -307,8 +329,8 @@ export function useAppNavigation() {
replace?: boolean;
replyId?: string;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId/posts/$postId",
params: {
Expand All @@ -322,7 +344,14 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: false,
},
),
{
kind: "forum-post",
channelId,
postId,
replyId: options?.replyId ?? null,
},
);
},
[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