mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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**  **Editing an actual thread reply uses the thread composer**  --------- 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:
100
desktop/src/app/navigation/navigationGuard.test.mjs
Normal file
100
desktop/src/app/navigation/navigationGuard.test.mjs
Normal 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);
|
||||
});
|
||||
54
desktop/src/app/navigation/navigationGuard.ts
Normal file
54
desktop/src/app/navigation/navigationGuard.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
9
desktop/src/features/channels/ui/GuardedChannelPane.tsx
Normal file
9
desktop/src/features/channels/ui/GuardedChannelPane.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
desktop/src/features/channels/ui/useChannelTargetReset.ts
Normal file
30
desktop/src/features/channels/ui/useChannelTargetReset.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
10
desktop/src/features/channels/ui/useNavigationGuard.ts
Normal file
10
desktop/src/features/channels/ui/useNavigationGuard.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -461,6 +461,7 @@ function InboxMessageDetailPane({
|
||||
author: editTarget.authorLabel,
|
||||
body: editTarget.content,
|
||||
id: editTarget.id,
|
||||
isThreadReply: false,
|
||||
imetaMedia: imetaMediaFromTags(editTarget.tags),
|
||||
...editMentionState,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.", [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -143,7 +143,7 @@ function MoreActionsMenu({
|
||||
{onEdit ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`edit-message-${message.id}`}
|
||||
onClick={() => {
|
||||
onSelect={() => {
|
||||
editJustSelectedRef.current = true;
|
||||
onEdit(message);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user