fix(messages): route edits to the owning composer (#6575)

**Category:** fix
**User Impact:** Editing a channel message now opens in the main
composer, while editing a thread reply stays in the thread composer,
with focus ready for typing.

**Problem:** When a thread was open, Buzz treated its root message as
thread-owned and opened edits in the thread composer. Menu-driven edits
also lacked regression coverage for immediate focus.

**Solution:** Carry the message's semantic root/reply classification
into the edit target, route only actual replies to the thread composer,
and use the menu primitive's selection event for a reliable handoff.
End-to-end tests cover placement and focus for both paths.

<details>
<summary>File changes</summary>

**desktop/src/features/channels/ui/ChannelPane.tsx**
Routes edit targets by semantic thread ownership rather than membership
in the open thread panel.

**desktop/src/features/channels/ui/ChannelPane.types.ts**
Uses the shared composer edit-target type so routing metadata stays
attached to the target.

**desktop/src/features/messages/lib/draftMentionRefs.ts**
Classifies each edit target as a root or true thread reply from its
event tags.

**desktop/src/features/messages/lib/draftMentionRefs.test.mjs**
Covers semantic ownership for root and reply edit targets.

**desktop/src/features/messages/ui/MessageActionBar.tsx**
Handles Edit through the dropdown menu's selection event so focus
restoration and edit startup share the intended lifecycle.

**desktop/src/features/messages/ui/MessageComposer.types.ts**
Adds semantic thread ownership to the edit-target contract.

**desktop/tests/e2e/messaging.spec.ts**
Verifies root edits use and focus the main composer, while reply edits
use and focus the thread composer.

</details>

### Reproduction Steps

1. Send a channel message and open its thread.
2. From the thread panel, edit the root message; confirm its content
loads in the main composer and the editor is focused.
3. Send a reply in that thread.
4. Edit the reply; confirm its content loads in the thread composer and
the editor is focused.


### Screenshots

**Editing a channel-root message uses the main composer**

![Channel-root message editing in the main composer, dark theme with
purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-root-main-dark.png)

**Editing an actual thread reply uses the thread composer**

![Thread reply editing in the thread composer, dark theme with purple
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-reply-thread-dark.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-24 13:06:00 -07:00
committed by GitHub
parent 6eff84d127
commit 4bf80978f5
28 changed files with 1799 additions and 94 deletions

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
const target = {
kind: "channel-message",
channelId: "general",
messageId: "message-a",
threadRootId: "thread-a",
};
const { allowNavigation, registerNavigationGuard, traverseHistory } =
await import("./navigationGuard.ts");
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("guarded history traversal blocks before mutating history", () => {
let received;
let backCalls = 0;
const unregister = registerNavigationGuard((nextTarget) => {
received = nextTarget;
return false;
});
assert.equal(
traverseHistory(
{
back: () => {
backCalls += 1;
},
forward: () => {},
},
"back",
),
false,
);
assert.deepEqual(received, { kind: "history", direction: "back" });
assert.equal(backCalls, 0);
unregister();
});
test("guarded history traversal invokes the selected direction when allowed", () => {
let forwardCalls = 0;
assert.equal(
traverseHistory(
{
back: () => {},
forward: () => {
forwardCalls += 1;
},
},
"forward",
),
true,
);
assert.equal(forwardCalls, 1);
});
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);
});

View File

@@ -0,0 +1,54 @@
export type GuardedNavigation =
| {
kind: "history";
direction: "back" | "forward";
}
| {
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 traverseHistory(
history: Pick<History, "back" | "forward">,
direction: "back" | "forward",
): boolean {
if (!allowNavigation({ kind: "history", direction })) {
return false;
}
history[direction]();
return 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);
};
}

View File

@@ -7,6 +7,11 @@ import {
} from "@tanstack/react-router";
import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import {
allowNavigation,
type GuardedNavigation,
traverseHistory,
} from "@/app/navigation/navigationGuard";
import type { SearchHit } from "@/shared/api/types";
type NavigationBehavior = {
@@ -30,6 +35,7 @@ export function useAppNavigation() {
state?: Record<string, unknown>;
},
behavior: NavigationBehavior = {},
guardedTarget?: GuardedNavigation,
) => {
const nextLocation = router.buildLocation(next as never);
@@ -37,6 +43,14 @@ export function useAppNavigation() {
return false;
}
if (
!allowNavigation(
guardedTarget ?? { kind: "route", href: nextLocation.href },
)
) {
return false;
}
await navigate({
...next,
replace: behavior.replace,
@@ -256,8 +270,8 @@ export function useAppNavigation() {
thread?: string;
threadRootId?: string | null;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId",
params: {
@@ -282,7 +296,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],
);
@@ -307,8 +330,8 @@ export function useAppNavigation() {
replace?: boolean;
replyId?: string;
},
) =>
commitNavigation(
) => {
return commitNavigation(
{
to: "/channels/$channelId/posts/$postId",
params: {
@@ -322,7 +345,14 @@ export function useAppNavigation() {
replace: options?.replace,
resetScroll: false,
},
),
{
kind: "forum-post",
channelId,
postId,
replyId: options?.replyId ?? null,
},
);
},
[commitNavigation],
);
@@ -340,7 +370,7 @@ export function useAppNavigation() {
const closeSettings = React.useCallback(() => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}
@@ -349,7 +379,7 @@ export function useAppNavigation() {
const closeWorkflowDetail = React.useCallback(() => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}
@@ -359,7 +389,7 @@ export function useAppNavigation() {
const closeForumPost = React.useCallback(
(channelId: string) => {
if (canGoBack) {
router.history.back();
traverseHistory(router.history, "back");
return;
}

View File

@@ -8,6 +8,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
import { traverseHistory } from "@/app/navigation/navigationGuard";
import { isMacPlatform } from "@/shared/lib/platform";
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
@@ -59,7 +60,7 @@ export function useBackForwardControls() {
return;
}
router.history.back();
traverseHistory(router.history, "back");
}, [canGoBack, router.history]);
const goForward = React.useCallback(() => {
@@ -67,7 +68,7 @@ export function useBackForwardControls() {
return;
}
router.history.forward();
traverseHistory(router.history, "forward");
}, [canGoForward, router.history]);
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {

View File

@@ -18,6 +18,7 @@ export function WorkflowsRouteScreen({
onEditorPaneChange,
}: WorkflowsRouteScreenProps) {
const {
closeWorkflowDetail,
goDuplicateWorkflow,
goEditWorkflow,
goNewWorkflow,
@@ -26,11 +27,11 @@ export function WorkflowsRouteScreen({
} = useAppNavigation();
const closeEditor = React.useCallback(() => {
if (editor?.hasOrigin) {
window.history.back();
closeWorkflowDetail();
return;
}
void goWorkflows({ replace: true });
}, [editor?.hasOrigin, goWorkflows]);
}, [closeWorkflowDetail, editor?.hasOrigin, goWorkflows]);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const memberChannels = channels.filter((channel) => channel.isMember);

View File

@@ -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";
@@ -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";
@@ -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(
@@ -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` →
@@ -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,
@@ -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}
>
@@ -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"
@@ -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}
@@ -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
@@ -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}

View File

@@ -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,
@@ -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;

View File

@@ -17,7 +17,6 @@ import {
} from "@/features/channels/readState/readStateFormat";
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
import { ChannelPane } from "@/features/channels/ui/ChannelScreenLazyViews";
import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog";
import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
@@ -45,7 +44,10 @@ import {
import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
getThreadReference,
isThreadReply,
} from "@/features/messages/lib/threading";
import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
import {
resolveTimelineLoadingLatch,
@@ -80,10 +82,13 @@ import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useMessageProfiles } from "./useMessageProfiles";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelTargetReset } from "./useChannelTargetReset";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelOpenReadState } from "./useChannelOpenReadState";
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,
@@ -168,11 +173,15 @@ export function ChannelScreen({
const activeChannelId = activeChannel?.id ?? null;
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
const requireThreadEditResolutionRef = React.useRef<() => boolean>(
() => true,
);
const effectiveOpenThreadHeadId = useHuddleThreadIsolation({
closeThread: setOpenThreadHeadId,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
requireThreadEditResolutionRef,
});
const isNotifiedForEffectiveThread =
effectiveOpenThreadHeadId != null
@@ -462,8 +471,10 @@ export function ChannelScreen({
});
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
[editTargetId, timelineMessages],
timelineMessages.find((message) => message.id === editTargetId) ??
threadPanelData.messages.find((message) => message.id === editTargetId) ??
null,
[editTargetId, threadPanelData.messages, timelineMessages],
);
const [emptyDeleteId, setEmptyDeleteId] = React.useState<string | null>(null);
const {
@@ -475,6 +486,7 @@ export function ChannelScreen({
handleEditSave,
handleExpandThreadReplies,
handleOpenThread,
requireThreadEditResolution,
handleSendMessage,
handleSendToChannel,
handleSendThreadReply,
@@ -484,6 +496,8 @@ export function ChannelScreen({
deleteMessageMutation,
editMessageMutation,
editTargetId,
editTargetIsThreadReply:
editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []),
expandedThreadReplyIds,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
@@ -502,6 +516,7 @@ export function ChannelScreen({
threadReplyTargetId,
toggleReactionMutation,
});
requireThreadEditResolutionRef.current = requireThreadEditResolution;
const effectiveToggleReaction = React.useMemo(
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
@@ -577,6 +592,7 @@ export function ChannelScreen({
openAgentSessionPubkey,
openThreadHeadId: effectiveOpenThreadHeadId,
profilePanelPubkey,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
@@ -590,6 +606,7 @@ export function ChannelScreen({
useChannelProfilePanel({
closeAgentSession: handleCloseAgentSession,
openProfilePanel,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -630,28 +647,19 @@ export function ChannelScreen({
timelineMessages,
isTimelineLoading,
);
const resetComposerTargets = React.useCallback(
(_channelId: string | null) => {
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
setEditTargetId(null);
},
[],
);
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
}, [clearMessageRouteTarget]);
React.useEffect(() => {
resetComposerTargets(activeChannelId);
}, [activeChannelId, resetComposerTargets]);
useChannelTargetReset({
activeChannelId,
setEditTargetId,
setExpandedThreadReplyIds,
setThreadReplyTargetId,
setThreadScrollTargetId,
});
useNavigationGuard(requireThreadEditResolution);
const mainTimelineTargetMessageId = useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession: handleCloseAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -710,6 +718,7 @@ export function ChannelScreen({
enabled: !isSinglePanelView,
});
const handleManageChannel = React.useCallback(() => {
if (!requireThreadEditResolution()) return;
if (activeChannel?.channelType === "forum") {
openGlobalChannelManagement();
return;
@@ -729,6 +738,7 @@ export function ChannelScreen({
activeChannel?.channelType,
channelManagementOpen,
openGlobalChannelManagement,
requireThreadEditResolution,
setChannelManagementOpen,
setOpenThreadHeadId,
handleCloseAgentSession,
@@ -839,7 +849,7 @@ export function ChannelScreen({
/>
}
>
<ChannelPane
<GuardedChannelPane
activeChannel={activeChannel}
activityAgents={channelAgentSessionAgents}
agentPubkeys={agentPubkeys}
@@ -930,11 +940,13 @@ export function ChannelScreen({
onSendToChannel={handleSendToChannel}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={
handleThreadScrollTargetResolved
onThreadScrollTargetResolved={() =>
setThreadScrollTargetId(null)
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onTargetReached={() =>
clearMessageRouteTarget({ replace: true })
}
onToggleReaction={effectiveToggleReaction}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}

View File

@@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
hasActiveEdit: boolean;
onClose: () => void;
};
@@ -139,6 +140,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
export function FocusThreadDrawer({
channelName,
children,
hasActiveEdit,
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
@@ -149,6 +151,14 @@ export function FocusThreadDrawer({
React.useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key !== "Escape") return;
const target = event.target;
if (
hasActiveEdit &&
target instanceof Node &&
drawerRef.current?.contains(target)
) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
onClose();
@@ -158,7 +168,7 @@ export function FocusThreadDrawer({
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [onClose]);
}, [hasActiveEdit, onClose]);
React.useLayoutEffect(() => {
previousFocusRef.current =

View File

@@ -0,0 +1,9 @@
import type * as React from "react";
import { ChannelPane } from "./ChannelScreenLazyViews";
export function GuardedChannelPane(
props: React.ComponentProps<typeof ChannelPane>,
) {
return <ChannelPane {...props} />;
}

View File

@@ -39,6 +39,7 @@ type UseChannelAgentSessionsOptions = {
openAgentSessionPubkey: string | null;
openThreadHeadId: string | null;
profilePanelPubkey?: string | null;
requireThreadEditResolution: () => boolean;
setChannelManagementOpen: (open: boolean) => void;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenAgentSessionChannelId: PanelValueSetter;
@@ -173,6 +174,7 @@ export function useChannelAgentSessions({
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey = null,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
@@ -209,6 +211,7 @@ export function useChannelAgentSessions({
const openAgentSession = React.useCallback(
(pubkey: string, channelId?: string | null) => {
if (!requireThreadEditResolution()) return;
if (!isAgentSessionOpen) {
returnTarget.capture(
resolveAgentSessionReturnTarget({
@@ -234,6 +237,7 @@ export function useChannelAgentSessions({
isAgentSessionOpen,
openThreadHeadId,
profilePanelPubkey,
requireThreadEditResolution,
returnTarget,
setChannelManagementOpen,
setExpandedThreadReplyIds,

View File

@@ -7,6 +7,7 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte
type UseChannelProfilePanelOptions = {
closeAgentSession: () => void;
openProfilePanel: (pubkey: string, options?: ProfilePanelOpenOptions) => void;
requireThreadEditResolution: () => boolean;
setChannelManagementOpen: (open: boolean) => void;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenThreadHeadId: (value: string | null) => void;
@@ -18,6 +19,7 @@ type UseChannelProfilePanelOptions = {
export function useChannelProfilePanel({
closeAgentSession,
openProfilePanel,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -30,6 +32,7 @@ export function useChannelProfilePanel({
const handleOpenProfilePanel = React.useCallback(
(pubkey: string, options?: ProfilePanelOpenOptions) => {
if (!requireThreadEditResolution()) return;
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
@@ -41,6 +44,7 @@ export function useChannelProfilePanel({
[
closeAgentSession,
openProfilePanel,
requireThreadEditResolution,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,

View File

@@ -56,6 +56,7 @@ export function useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -68,6 +69,7 @@ export function useChannelRouteTarget({
activeChannel: Channel | null;
activeChannelId: string | null;
closeAgentSession: () => void;
requireThreadEditResolution: () => boolean;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setOpenThreadHeadId: PanelValueSetter;
@@ -115,13 +117,14 @@ export function useChannelRouteTarget({
}
if (!targetMessage.parentId) {
if (!requireThreadEditResolution()) {
return;
}
closeAgentSession();
// Root message links should open the reply panel for that root. The
// timeline scroll/highlight target alone is not enough: root links have
// no parent/thread metadata, so the reply-only branch below cannot infer
// a thread head.
setProfilePanelPubkey(null, { replace: true });
setEditTargetId(null);
// Root message links open the reply panel. Navigation is refused before
// this route target is accepted when another composer owns a dirty edit.
setOpenThreadHeadId(targetMessage.id, { replace: true });
setThreadReplyTargetId(targetMessage.id);
setThreadScrollTargetId(null);
@@ -141,6 +144,9 @@ export function useChannelRouteTarget({
if (!routeTarget) {
return;
}
if (!requireThreadEditResolution()) {
return;
}
closeAgentSession();
// Replace so the deep-link entry itself carries the opened thread —
@@ -156,6 +162,7 @@ export function useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,

View File

@@ -0,0 +1,30 @@
import * as React from "react";
export function useChannelTargetReset({
activeChannelId,
setEditTargetId,
setExpandedThreadReplyIds,
setThreadReplyTargetId,
setThreadScrollTargetId,
}: {
activeChannelId: string | null;
setEditTargetId: (id: string | null) => void;
setExpandedThreadReplyIds: (ids: Set<string>) => void;
setThreadReplyTargetId: (id: string | null) => void;
setThreadScrollTargetId: (id: string | null) => void;
}) {
React.useEffect(() => {
// The channel identity is intentionally the reset trigger.
void activeChannelId;
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
setEditTargetId(null);
}, [
activeChannelId,
setEditTargetId,
setExpandedThreadReplyIds,
setThreadReplyTargetId,
setThreadScrollTargetId,
]);
}

View File

@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveHuddleOpenThreadHeadId } from "./useHuddleThreadIsolation.ts";
test("huddle transcripts synchronously hide URL thread state", () => {
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: true,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: undefined,
}),
null,
);
});
test("an optimistic null overrides the URL thread until navigation settles", () => {
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: false,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: null,
}),
null,
);
assert.equal(
resolveHuddleOpenThreadHeadId({
isHuddleTranscript: false,
openThreadHeadId: "url-thread",
optimisticOpenThreadHeadId: undefined,
}),
"url-thread",
);
});

View File

@@ -5,20 +5,43 @@ type HuddleThreadIsolationOptions = {
isHuddleTranscript: boolean;
openThreadHeadId: string | null;
optimisticOpenThreadHeadId: string | null | undefined;
requireThreadEditResolutionRef: React.RefObject<() => boolean>;
};
export function resolveHuddleOpenThreadHeadId({
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
}: Pick<
HuddleThreadIsolationOptions,
"isHuddleTranscript" | "openThreadHeadId" | "optimisticOpenThreadHeadId"
>): string | null {
if (isHuddleTranscript) return null;
return optimisticOpenThreadHeadId === undefined
? openThreadHeadId
: optimisticOpenThreadHeadId;
}
export function useHuddleThreadIsolation({
closeThread,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
requireThreadEditResolutionRef,
}: HuddleThreadIsolationOptions): string | null {
React.useEffect(() => {
if (!isHuddleTranscript || openThreadHeadId === null) return;
if (!requireThreadEditResolutionRef.current()) return;
closeThread(null);
}, [closeThread, isHuddleTranscript, openThreadHeadId]);
if (isHuddleTranscript) return null;
return optimisticOpenThreadHeadId === undefined
? openThreadHeadId
: optimisticOpenThreadHeadId;
}, [
closeThread,
isHuddleTranscript,
openThreadHeadId,
requireThreadEditResolutionRef,
]);
return resolveHuddleOpenThreadHeadId({
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
});
}

View File

@@ -0,0 +1,10 @@
import * as React from "react";
import { registerNavigationGuard } from "@/app/navigation/navigationGuard";
export function useNavigationGuard(requireThreadEditResolution: () => boolean) {
React.useLayoutEffect(
() => registerNavigationGuard(() => requireThreadEditResolution()),
[requireThreadEditResolution],
);
}

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { toast } from "sonner";
import type {
useDeleteMessageMutation,
@@ -24,6 +25,7 @@ export function useChannelPaneHandlers({
deleteMessageMutation,
editMessageMutation,
editTargetId,
editTargetIsThreadReply,
expandedThreadReplyIds,
getFirstReplyIdForMessage,
getReplyDescendantIdsForMessage,
@@ -45,6 +47,7 @@ export function useChannelPaneHandlers({
deleteMessageMutation: ReturnType<typeof useDeleteMessageMutation>;
editMessageMutation: ReturnType<typeof useEditMessageMutation>;
editTargetId: string | null;
editTargetIsThreadReply: boolean;
expandedThreadReplyIds: ReadonlySet<string>;
getFirstReplyIdForMessage: (messageId: string) => string | null;
getReplyDescendantIdsForMessage: (messageId: string) => string[];
@@ -74,6 +77,8 @@ export function useChannelPaneHandlers({
const editTargetIdRef = React.useRef(editTargetId);
editTargetIdRef.current = editTargetId;
const editTargetIsThreadReplyRef = React.useRef(editTargetIsThreadReply);
editTargetIsThreadReplyRef.current = editTargetIsThreadReply;
const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds);
expandedThreadReplyIdsRef.current = expandedThreadReplyIds;
@@ -117,7 +122,16 @@ export function useChannelPaneHandlers({
setThreadReplyTargetId(openThreadHeadIdRef.current);
}, [setThreadReplyTargetId]);
const requireThreadEditResolution = React.useCallback(() => {
if (!editTargetIsThreadReplyRef.current) return true;
toast.info("Finish or cancel your edit before leaving the thread.");
return false;
}, []);
const handleCloseThread = React.useCallback(() => {
if (!requireThreadEditResolution()) {
return;
}
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
@@ -128,6 +142,7 @@ export function useChannelPaneHandlers({
}, [
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
requireThreadEditResolution,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setThreadReplyTargetId,
@@ -135,6 +150,8 @@ export function useChannelPaneHandlers({
]);
const handleCancelEdit = React.useCallback(() => {
editTargetIdRef.current = null;
editTargetIsThreadReplyRef.current = false;
setEditTargetId(null);
}, [setEditTargetId]);
@@ -198,6 +215,7 @@ export function useChannelPaneHandlers({
const handleOpenThread = React.useCallback(
(message: { id: string }) => {
if (!requireThreadEditResolution()) return;
if (openThreadHeadIdRef.current === message.id) {
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
@@ -222,6 +240,7 @@ export function useChannelPaneHandlers({
[
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -413,6 +432,7 @@ export function useChannelPaneHandlers({
handleEditSave,
handleExpandThreadReplies,
handleOpenThread,
requireThreadEditResolution,
handleSendMessage,
handleSendToChannel,
handleSendThreadReply,

View File

@@ -461,6 +461,7 @@ function InboxMessageDetailPane({
author: editTarget.authorLabel,
body: editTarget.content,
id: editTarget.id,
isThreadReply: false,
imetaMedia: imetaMediaFromTags(editTarget.tags),
...editMentionState,
}

View File

@@ -683,11 +683,17 @@ export function useSendMessageMutation(
}
const queryKey = channelMessagesKey(effectiveChannel.id);
await queryClient.cancelQueries({ queryKey });
const windowKey = channelWindowKey(effectiveChannel.id);
// The rendered timeline is projected from the channel-window cache. Cancel
// both reads before snapshotting either cache so an older window response
// cannot replace the optimistic row between onMutate and onSuccess.
await Promise.all([
queryClient.cancelQueries({ queryKey }),
queryClient.cancelQueries({ queryKey: windowKey }),
]);
const previousMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const windowKey = channelWindowKey(effectiveChannel.id);
const previousWindow =
queryClient.getQueryData<ChannelWindowStore>(windowKey);
const optimisticMessage = createOptimisticMessage(

View File

@@ -69,6 +69,37 @@ test("edit target preserves tagged identities while profiles are unavailable", (
assert.deepEqual(target.unresolvedMentionPubkeys, [ALICE, BOB]);
});
test("edit target records semantic thread ownership", () => {
const root = buildMessageComposerEditTarget(
message("Root", [["h", "channel-id"]]),
undefined,
() => false,
);
const reply = buildMessageComposerEditTarget(
message("Reply", [
["h", "channel-id"],
["e", "root-id", "", "root"],
["e", "root-id", "", "reply"],
]),
undefined,
() => false,
);
const broadcastReply = buildMessageComposerEditTarget(
message("Broadcast reply", [
["h", "channel-id"],
["e", "root-id", "", "reply"],
["broadcast", "1"],
]),
undefined,
() => false,
);
assert.equal(root.isThreadReply, false);
assert.equal(reply.isThreadReply, true);
assert.equal(broadcastReply.isThreadReply, false);
});
test("edit target separates resolved refs from identities missing profiles", () => {
const target = buildMessageComposerEditTarget(
message("Please review this, @Alice and @Bob.", [

View File

@@ -1,5 +1,6 @@
import { hasMention } from "@/features/messages/lib/hasMention";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { isThreadReply } from "@/features/messages/lib/threading";
import type { DraftMentionRef } from "@/features/messages/lib/useDrafts";
import type { TimelineMessage } from "@/features/messages/types";
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
@@ -95,6 +96,7 @@ export function buildMessageComposerEditTarget(
author: message.author,
body: message.body,
id: message.id,
isThreadReply: isThreadReply(message.tags ?? []),
imetaMedia: imetaMediaFromTags(message.tags),
...mentionState,
};

View File

@@ -143,7 +143,7 @@ function MoreActionsMenu({
{onEdit ? (
<DropdownMenuItem
data-testid={`edit-message-${message.id}`}
onClick={() => {
onSelect={() => {
editJustSelectedRef.current = true;
onEdit(message);
}}

View File

@@ -10,6 +10,7 @@ export type MessageComposerEditTarget = {
author: string;
body: string;
id: string;
isThreadReply: boolean;
/**
* NIP-92 imeta attachments on the original event, in tag order. Loaded
* into the composer's pending-imeta state on edit-open so the user sees

View File

@@ -359,6 +359,58 @@ test("failed community clear quarantines stale navigation from the next listener
await resetNavigationDeepLinkDrain();
});
test("refused navigation stays at the FIFO head and retries with one acknowledgement", async () => {
const pending = {
id: "retry-me",
kind: "message",
channelId: "channel-1",
messageId: "message-1",
threadRootId: "root-1",
};
const queue = [pending];
const opened = [];
const acknowledged = [];
ipcHandlers.set("plugin:event|listen", () => nextCallbackId);
ipcHandlers.set("plugin:event|unlisten", () => {});
ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null);
ipcHandlers.set("acknowledge_pending_navigation_deep_link", ({ id }) => {
assert.equal(queue[0]?.id, id);
acknowledged.push(id);
queue.shift();
return true;
});
const firstUnlisten = await listenForNavigationDeepLinks(
() => true,
(payload) => {
opened.push(`refused:${payload.messageId}`);
return false;
},
);
await settle();
assert.deepEqual(opened, ["refused:message-1"]);
assert.equal(queue[0], pending);
assert.deepEqual(acknowledged, []);
firstUnlisten();
const secondUnlisten = await listenForNavigationDeepLinks(
() => true,
(payload) => {
opened.push(`accepted:${payload.messageId}`);
return true;
},
);
await settle();
await settle();
assert.deepEqual(opened, ["refused:message-1", "accepted:message-1"]);
assert.deepEqual(acknowledged, ["retry-me"]);
assert.equal(queue.length, 0);
secondUnlisten();
});
test("rejected navigation remains queued and is not acknowledged", async () => {
const pending = {
id: "retry-me",

View File

@@ -1752,13 +1752,9 @@ function MarkdownInner({
const onOpenEntityLink = useOpenEntityLink();
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
// route already handles scroll-into-view + highlight via
// Always route through `goChannel` with `messageId` set: the navigation
// boundary guards every message-targeting caller before URL mutation.
// `useAnchoredScroll` + `getEventById` backfill, and works for
// both stream-message replies and forum threads. Detecting "the thread
// root is a forum post" up front would require an event lookup we don't
// currently have synchronously; the brief explicitly allows skipping
// that detection and falling through.
void goChannel(link.channelId, {
messageId: link.messageId,
threadRootId: link.threadRootId,

View File

@@ -32,11 +32,10 @@ export function useMessageDeepLinks(enabled = true) {
},
async (payload) => {
if (cancelled) return false;
await goChannel(payload.channelId, {
return goChannel(payload.channelId, {
messageId: payload.messageId,
threadRootId: payload.threadRootId,
});
return true;
},
);
return () => {

File diff suppressed because it is too large Load Diff