From 8cc27ba05bcf2b1c51d43c21b92ea2b43e0e393c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sat, 8 Aug 2026 08:35:13 +0100 Subject: [PATCH 01/24] Add Send to channel for thread messages Signed-off-by: kenny lopez --- .../src/features/channels/ui/ChannelPane.tsx | 6 +- .../features/channels/ui/ChannelPane.types.ts | 5 + .../features/channels/ui/ChannelScreen.tsx | 7 +- .../channels/useChannelPaneHandlers.ts | 18 ++ desktop/src/features/messages/hooks.ts | 34 +++- .../messages/lib/canSendToChannel.test.mjs | 48 ++++++ .../features/messages/lib/canSendToChannel.ts | 21 +++ .../messages/lib/sentFromThread.test.mjs | 69 ++++++++ .../features/messages/lib/sentFromThread.ts | 60 +++++++ .../features/messages/ui/MessageActionBar.tsx | 33 ++++ .../src/features/messages/ui/MessageRow.tsx | 29 ++++ .../messages/ui/MessageThreadPanel.tsx | 18 +- .../messages/ui/SentFromThreadLine.tsx | 51 ++++++ desktop/src/shared/ui/icons.ts | 9 + desktop/src/shared/ui/markdown.tsx | 2 - .../shared/ui/markdown/MessageLinkPill.tsx | 22 +-- desktop/src/shared/ui/markdown/types.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 156 ++++++++++++++++++ desktop/tests/e2e/navigation.spec.ts | 16 +- 19 files changed, 577 insertions(+), 29 deletions(-) create mode 100644 desktop/src/features/messages/lib/canSendToChannel.test.mjs create mode 100644 desktop/src/features/messages/lib/canSendToChannel.ts create mode 100644 desktop/src/features/messages/lib/sentFromThread.test.mjs create mode 100644 desktop/src/features/messages/lib/sentFromThread.ts create mode 100644 desktop/src/features/messages/ui/SentFromThreadLine.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7ea63f2339..a690129107 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -131,6 +131,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, + onSendToChannel, onSendVideoReviewComment, onSendThreadReply, onThreadScrollTargetResolved, @@ -295,9 +296,7 @@ export const ChannelPane = React.memo(function ChannelPane({ 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` → // ordinary DM, composer enabled. @@ -874,6 +873,9 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} + onSendToChannel={ + isComposerDisabled ? undefined : onSendToChannel + } onScrollTargetResolved={() => resolveScrollTarget()} onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 763b8bf379..3c5cb63d8c 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -107,6 +107,11 @@ export type ChannelPaneProps = { mediaTags?: string[][], channelId?: string | null, ) => Promise; + onSendToChannel: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onSendVideoReviewComment?: ( message: TimelineMessage, content: string, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 8666394938..56a1976487 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -84,8 +84,7 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, - EMPTY_RELAY_EVENTS: RelayEvent[] = []; +const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, @@ -495,6 +494,7 @@ export function ChannelScreen({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, @@ -713,7 +713,7 @@ export function ChannelScreen({ const shouldCompactHeaderActions = hasAuxiliaryPanel && channelContentWidthPx > 0 && - channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX; + channelContentWidthPx < 760; const channelHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, ...channelContentTopPaddingMeasurement, @@ -940,6 +940,7 @@ export function ChannelScreen({ onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetResolved={ diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 7e78d9601f..d784c1daac 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -7,6 +7,7 @@ import type { useToggleReactionMutation, } from "@/features/messages/hooks"; import { resolveThreadReplyTarget } from "@/features/messages/hooks"; +import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; import type { TimelineMessage } from "@/features/messages/types"; /** @@ -287,6 +288,22 @@ export function useChannelPaneHandlers({ [], ); + const handleSendToChannel = React.useCallback( + async ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => { + await sendMutateRef.current({ + channelId, + content: message.body, + sentFromThreadRootExcerpt: summarizeThreadRoot(threadRoot.body), + sentFromThreadRootId: threadRoot.id, + }); + }, + [], + ); + const handleSendThreadReply = React.useCallback( async ( content: string, @@ -376,6 +393,7 @@ export function useChannelPaneHandlers({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b..ec2a81b837 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -27,6 +27,7 @@ import { export { mergeMessages, mergeTimelineCacheMessages }; import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { messageMentionPubkeys } from "@/features/messages/lib/messageMentionPubkeys"; +import { buildSentFromThreadTag } from "@/features/messages/lib/sentFromThread"; import { clearTimeoutState, recordTimeoutFromRejection, @@ -87,6 +88,8 @@ export function createOptimisticMessage( mentionPubkeys: string[] = [], parentEventId: string | null = null, mediaTags: string[][] = [], + sentFromThreadRootId: string | null = null, + sentFromThreadRootExcerpt: string | null = null, ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -115,6 +118,11 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + if (sentFromThreadRootId) { + tags.push( + buildSentFromThreadTag(sentFromThreadRootId, sentFromThreadRootExcerpt), + ); + } return { id: localKey, @@ -413,6 +421,8 @@ export function useSendMessageMutation( mentionPubkeys?: string[]; parentEventId?: string | null; mediaTags?: string[][]; + sentFromThreadRootId?: string | null; + sentFromThreadRootExcerpt?: string | null; }, MessageQueryContext | undefined >({ @@ -423,6 +433,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -464,6 +476,12 @@ export function useSendMessageMutation( identity.pubkey, mentionPubkeys, ); + if ( + sentFromThreadRootId && + (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) + ) { + throw new Error("A thread message can only be sent as top-level text."); + } // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra @@ -529,7 +547,17 @@ export function useSendMessageMutation( effectiveChannel.id, content, recipientPubkeys, - mentionTags, + [ + ...mentionTags, + ...(sentFromThreadRootId + ? [ + buildSentFromThreadTag( + sentFromThreadRootId, + sentFromThreadRootExcerpt, + ), + ] + : []), + ], ); }, onMutate: async ({ @@ -539,6 +567,8 @@ export function useSendMessageMutation( mentionPubkeys, parentEventId, mediaTags, + sentFromThreadRootId, + sentFromThreadRootExcerpt, }) => { // Mirror mutationFn's target resolution so the optimistic message lands // in the cache for the same channel as the real send. A caller-supplied @@ -574,6 +604,8 @@ export function useSendMessageMutation( mentionPubkeys ?? [], parentEventId ?? null, mediaTags ?? [], + sentFromThreadRootId ?? null, + sentFromThreadRootExcerpt ?? null, ); const nextWindow = mergeLiveChannelWindowEvent( diff --git a/desktop/src/features/messages/lib/canSendToChannel.test.mjs b/desktop/src/features/messages/lib/canSendToChannel.test.mjs new file mode 100644 index 0000000000..b773270ee2 --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "./canSendToChannel.ts"; + +const CURRENT = "a".repeat(64); +const OWNED_AGENT = "b".repeat(64); +const OTHER_PERSON = "c".repeat(64); +const OTHER_AGENT = "d".repeat(64); + +const message = (pubkey) => ({ kind: 9, pubkey }); +const profiles = { + [OWNED_AGENT]: { isAgent: true, ownerPubkey: CURRENT }, + [OTHER_AGENT]: { isAgent: true, ownerPubkey: OTHER_PERSON }, +}; + +test("send-to-channel permits self-authored messages", () => { + assert.equal( + canSendMessageToChannel(message(CURRENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel permits messages from an agent owned by the viewer", () => { + assert.equal( + canSendMessageToChannel(message(OWNED_AGENT), CURRENT, profiles), + true, + ); +}); + +test("send-to-channel rejects third-party people and agents", () => { + assert.equal( + canSendMessageToChannel(message(OTHER_PERSON), CURRENT, profiles), + false, + ); + assert.equal( + canSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + false, + ); + assert.throws( + () => + assertCanSendMessageToChannel(message(OTHER_AGENT), CURRENT, profiles), + /only send your own or your agents' messages/, + ); +}); diff --git a/desktop/src/features/messages/lib/canSendToChannel.ts b/desktop/src/features/messages/lib/canSendToChannel.ts new file mode 100644 index 0000000000..1d104bbdea --- /dev/null +++ b/desktop/src/features/messages/lib/canSendToChannel.ts @@ -0,0 +1,21 @@ +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; + +export function canSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): boolean { + return canManageMessageForCurrentUser(message, currentPubkey, profiles); +} + +export function assertCanSendMessageToChannel( + message: TimelineMessage, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): void { + if (!canSendMessageToChannel(message, currentPubkey, profiles)) { + throw new Error("You can only send your own or your agents' messages."); + } +} diff --git a/desktop/src/features/messages/lib/sentFromThread.test.mjs b/desktop/src/features/messages/lib/sentFromThread.test.mjs new file mode 100644 index 0000000000..6591ef6f70 --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildSentFromThreadTag, + getSentFromThreadReference, + getSentFromThreadRootId, + SENT_FROM_THREAD_TAG, + summarizeThreadRoot, +} from "./sentFromThread.ts"; + +test("buildSentFromThreadTag records the normalized root event ID", () => { + assert.deepEqual(buildSentFromThreadTag(" root-event "), [ + SENT_FROM_THREAD_TAG, + "root-event", + ]); +}); + +test("buildSentFromThreadTag includes a normalized human excerpt", () => { + assert.deepEqual(buildSentFromThreadTag("root-event", " Launch plan "), [ + SENT_FROM_THREAD_TAG, + "root-event", + "Launch plan", + ]); +}); + +test("buildSentFromThreadTag rejects an empty root event ID", () => { + assert.throws( + () => buildSentFromThreadTag(" "), + /thread root event ID is required/, + ); +}); + +test("sent-from-thread references accept an optional excerpt", () => { + assert.equal( + getSentFromThreadRootId([ + ["h", "channel-id"], + [SENT_FROM_THREAD_TAG, "root-event"], + ]), + "root-event", + ); + assert.deepEqual( + getSentFromThreadReference([ + [SENT_FROM_THREAD_TAG, "root-event", "Root summary"], + ]), + { rootEventId: "root-event", rootExcerpt: "Root summary" }, + ); + assert.equal( + getSentFromThreadRootId([ + [SENT_FROM_THREAD_TAG, "root-event", "summary", "extra"], + ]), + null, + ); + assert.equal(getSentFromThreadRootId([[SENT_FROM_THREAD_TAG, " "]]), null); + assert.equal(getSentFromThreadRootId(undefined), null); +}); + +test("summarizeThreadRoot keeps concise text and ignores media-only roots", () => { + assert.equal(summarizeThreadRoot(" **Launch** plan "), "Launch plan"); + assert.equal( + summarizeThreadRoot("![diagram](https://example.com/diagram.png)"), + null, + ); + assert.equal( + summarizeThreadRoot("See [the plan](https://example.com/plan) for details"), + "See the plan for details", + ); + assert.match(summarizeThreadRoot("word ".repeat(30)) ?? "", /…$/); +}); diff --git a/desktop/src/features/messages/lib/sentFromThread.ts b/desktop/src/features/messages/lib/sentFromThread.ts new file mode 100644 index 0000000000..58b205b62d --- /dev/null +++ b/desktop/src/features/messages/lib/sentFromThread.ts @@ -0,0 +1,60 @@ +export const SENT_FROM_THREAD_TAG = "buzz:sent-from-thread"; +const THREAD_ROOT_EXCERPT_MAX_LENGTH = 64; + +export type SentFromThreadReference = { + rootEventId: string; + rootExcerpt: string | null; +}; + +export function summarizeThreadRoot(content: string): string | null { + const normalized = content + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/?/g, " ") + .replace(/[`*_~>#|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!normalized) return null; + if (normalized.length <= THREAD_ROOT_EXCERPT_MAX_LENGTH) return normalized; + const clipped = normalized.slice(0, THREAD_ROOT_EXCERPT_MAX_LENGTH - 1); + const lastSpace = clipped.lastIndexOf(" "); + const excerpt = lastSpace > 32 ? clipped.slice(0, lastSpace) : clipped; + return `${excerpt.trimEnd()}…`; +} + +export function buildSentFromThreadTag( + rootEventId: string, + rootExcerpt?: string | null, +): string[] { + const normalizedRootEventId = rootEventId.trim(); + if (!normalizedRootEventId) { + throw new Error("A thread root event ID is required."); + } + + const normalizedExcerpt = rootExcerpt?.trim(); + return normalizedExcerpt + ? [SENT_FROM_THREAD_TAG, normalizedRootEventId, normalizedExcerpt] + : [SENT_FROM_THREAD_TAG, normalizedRootEventId]; +} + +export function getSentFromThreadReference( + tags: readonly (readonly string[])[] | null | undefined, +): SentFromThreadReference | null { + const tag = tags?.find( + (candidate) => + (candidate.length === 2 || candidate.length === 3) && + candidate[0] === SENT_FROM_THREAD_TAG, + ); + const rootEventId = tag?.[1]?.trim(); + if (!rootEventId) return null; + return { + rootEventId, + rootExcerpt: tag?.[2]?.trim() || null, + }; +} + +export function getSentFromThreadRootId( + tags: readonly (readonly string[])[] | null | undefined, +): string | null { + return getSentFromThreadReference(tags)?.rootEventId ?? null; +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 967e50f5d2..11163aa8c7 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -14,6 +14,7 @@ import { Trash2, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; @@ -36,6 +37,7 @@ import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { Button } from "@/shared/ui/button"; +import { HashArrowIn } from "@/shared/ui/icons"; import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, @@ -61,6 +63,7 @@ function MoreActionsMenu({ onMarkRead, onOpenChange, onRemindLater, + onSendToChannel, onUnfollowThread, open, isFollowingThread, @@ -77,6 +80,7 @@ function MoreActionsMenu({ onMarkRead?: (message: TimelineMessage) => void; onOpenChange: (open: boolean) => void; onRemindLater?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; open: boolean; isFollowingThread?: boolean; @@ -213,6 +217,31 @@ function MoreActionsMenu({ ) : null} + {onSendToChannel ? ( + { + void onSendToChannel(message) + .then(() => toast.success("Sent to channel")) + .catch((error) => { + console.error( + "Failed to send thread message to channel", + error, + ); + toast.error("Couldn't send to channel"); + }); + }} + > + + ) : null} + {hasCopyActions && channelId ? ( Promise; onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; reactions: TimelineReaction[]; @@ -398,6 +429,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ Boolean(onFollowThread) || Boolean(onUnfollowThread) || Boolean(onRemindLater) || + Boolean(onSendToChannel) || !message.pending; const wouldAddReaction = React.useCallback( @@ -545,6 +577,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ onMarkRead={onMarkRead} onOpenChange={setIsDropdownOpen} onRemindLater={onRemindLater} + onSendToChannel={onSendToChannel} onUnfollowThread={onUnfollowThread} open={isDropdownOpen} isFollowingThread={isFollowingThread} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 22bb355d70..170f7264e1 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -7,6 +7,10 @@ import { reactionsEqual, tagsEqual, } from "@/features/messages/lib/messageRowEquality"; +import { + assertCanSendMessageToChannel, + canSendMessageToChannel, +} from "@/features/messages/lib/canSendToChannel"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; @@ -47,6 +51,7 @@ import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; +import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -63,6 +68,7 @@ export type ThreadDepthGuideAction = { export const MessageRow = React.memo( function MessageRow({ channelId = null, + currentPubkey, collapseDepthGuideActions, connectDescendants = false, depthGuideDepths, @@ -92,6 +98,7 @@ export const MessageRow = React.memo( onMarkRead, onToggleReaction, onReply, + onSendToChannel, onEntranceComplete, playEntrance = false, onUnfollowThread, @@ -102,6 +109,7 @@ export const MessageRow = React.memo( videoReviewContext, }: { channelId?: string | null; + currentPubkey?: string; collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; depthGuideDepths?: ReadonlyArray; @@ -141,6 +149,7 @@ export const MessageRow = React.memo( remove: boolean, ) => Promise; onReply?: (message: TimelineMessage) => void; + onSendToChannel?: (message: TimelineMessage) => Promise; onUnfollowThread?: (message: TimelineMessage) => void; onEntranceComplete?: (messageId: string) => void; playEntrance?: boolean; @@ -190,6 +199,18 @@ export const MessageRow = React.memo( }, [channelId, openReminder], ); + const sendToChannelAllowed = canSendMessageToChannel( + message, + currentPubkey, + profiles, + ); + const handleSendToChannel = React.useCallback( + async (target: TimelineMessage) => { + assertCanSendMessageToChannel(target, currentPubkey, profiles); + await onSendToChannel?.(target); + }, + [currentPubkey, onSendToChannel, profiles], + ); const { mentionNames, mentionPubkeysByName } = React.useMemo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], @@ -539,6 +560,11 @@ export const MessageRow = React.memo( } onRemindLater={handleRemindLater} onReply={onReply} + onSendToChannel={ + onSendToChannel && sendToChannelAllowed + ? handleSendToChannel + : undefined + } onUnfollowThread={onUnfollowThread} reactionErrorMessage={reactionErrorMessage} reactions={reactions} @@ -616,6 +642,7 @@ export const MessageRow = React.memo( const messageBodyNode = ( <> + {renderBody()} {continuationMetadataNode} Promise; + onSendToChannel?: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onToggleReaction?: ( message: TimelineMessage, emoji: string, @@ -219,6 +224,7 @@ export function MessageThreadPanel({ onScrollTargetSettled, onSelectReplyTarget, onSend, + onSendToChannel, onToggleReaction, onUnfollowThread, profiles, @@ -522,7 +528,6 @@ export function MessageThreadPanel({ "padding", settleAtBottomAfterLayout, ); - const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { if ( @@ -546,11 +551,14 @@ export function MessageThreadPanel({ knownAgentPubkeys.has(pubkey) || profiles?.[pubkey]?.isAgent === true, ); }, [currentPubkey, knownAgentPubkeys, profiles, threadHead]); - if (!threadHead) { return null; } - + const sendToChannel = + onSendToChannel && channelId + ? (message: TimelineMessage) => + onSendToChannel(message, threadHead, channelId) + : undefined; const threadScrollRegion = ( onUnfollowThread() : undefined @@ -713,6 +723,7 @@ export function MessageThreadPanel({ {showUnreadDivider ? : null} { + void goChannel(target.channelId, { + messageId: target.messageId, + threadRootId: target.threadRootId, + }); + }, + [goChannel], + ); + + if (!channelId || !reference) return null; + const link: ParsedMessageLink = { + channelId, + messageId: reference.rootEventId, + threadRootId: reference.rootEventId, + }; + + return ( +
+ Sent from thread + +
+ ); +} diff --git a/desktop/src/shared/ui/icons.ts b/desktop/src/shared/ui/icons.ts index 4c2da1dace..804fa63f3d 100644 --- a/desktop/src/shared/ui/icons.ts +++ b/desktop/src/shared/ui/icons.ts @@ -9,6 +9,15 @@ export const HashSearch = createLucideIcon("hash-search", [ ["circle", { cx: "17", cy: "17", r: "3", key: "18b49y" }], ]); +export const HashArrowIn = createLucideIcon("hash-arrow-in", [ + ["line", { x1: "4", x2: "20", y1: "9", y2: "9", key: "pulu6f" }], + ["line", { x1: "4", x2: "11", y1: "15", y2: "15", key: "th0qa4" }], + ["line", { x1: "10", x2: "8", y1: "3", y2: "21", key: "1ggp8o" }], + ["line", { x1: "16", x2: "15", y1: "3", y2: "12", key: "noe5so" }], + ["path", { d: "M21 18h-7", key: "1c9c8q" }], + ["path", { d: "m17 15-3 3 3 3", key: "18z0pk" }], +]); + export const ListSortDescending = createLucideIcon("list-sort-descending", [ ["path", { d: "M15 12H3", key: "1d0spu" }], ["path", { d: "M3 5h18", key: "d7x3do" }], diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 414b44a966..61b31c1869 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1366,7 +1366,6 @@ function createMarkdownComponents( return ( c.id === link.channelId); const channelLabel = channel?.name ?? "channel"; - const shortId = link.messageId.slice(0, 6); - const label = ( - <> - #{channelLabel} · {shortId} - - ); + const baseLabel = `Thread in #${channelLabel}`; + const label = threadExcerpt ? `${baseLabel} — ${threadExcerpt}` : baseLabel; if (!interactive) { - return {label}; + return ( + + {label} + + ); } return ( + + ); + } + return ( ); } diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 440ee5cf35..8c67548fa8 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1301,7 +1301,7 @@ test("sends a thread message to its parent channel with a root-thread link", asy page, }) => { const timestamp = Date.now(); - const rootContent = `Share source thread ${timestamp}`; + const rootContent = `🧵 Share source thread ${timestamp}`; const priorChannelMessage = `Prior channel message ${timestamp}`; const replySummary = `Share this reply ${timestamp}`; const attachmentSha = "d".repeat(64); @@ -1515,11 +1515,17 @@ test("sends a thread message to its parent channel with a root-thread link", asy await expect(rootLink).toHaveClass(/max-w-80/); await expect(rootLink).toHaveClass(/truncate/); await expect(rootLink).toHaveClass(/inline-block/); - await expect(rootLink).toHaveClass(/border-b/); - await expect(rootLink).toHaveClass(/border-transparent/); - await expect(rootLink).toHaveClass(/hover:border-current/); await expect(rootLink).toHaveClass(/font-medium/); await expect(rootLink).not.toHaveClass(/mention-chip/); + await expect(rootLink).not.toHaveClass(/border-b/); + const rootLinkText = rootLink.locator("[data-message-link-text]"); + const rootLinkEmoji = rootLink.locator("[data-message-link-emoji]"); + await expect(rootLinkText).toHaveText(` Share source thread ${timestamp}`); + await expect(rootLinkText).toHaveClass(/border-b/); + await expect(rootLinkText).toHaveClass(/border-transparent/); + await expect(rootLinkText).toHaveClass(/group-hover:border-current/); + await expect(rootLinkEmoji).toHaveText("🧵"); + await expect(rootLinkEmoji).not.toHaveClass(/border-b/); const [prefixColor, linkColorBeforeHover] = await Promise.all([ sourcePrefix.evaluate((element) => getComputedStyle(element).color), rootLink.evaluate((element) => getComputedStyle(element).color), @@ -1532,7 +1538,7 @@ test("sends a thread message to its parent channel with a root-thread link", asy .toBe("rgba(0, 0, 0, 0)"); await expect .poll(() => - rootLink.evaluate( + rootLinkText.evaluate( (element) => getComputedStyle(element).borderBottomColor, ), ) @@ -1544,13 +1550,21 @@ test("sends a thread message to its parent channel with a root-thread link", asy .toBe(linkColorBeforeHover); await expect .poll(() => - rootLink.evaluate((element) => { + rootLinkText.evaluate((element) => { const style = getComputedStyle(element); return style.borderBottomColor === style.color; }), ) .toBe(true); + await expect + .poll(() => + rootLinkEmoji.evaluate( + (element) => getComputedStyle(element).borderBottomWidth, + ), + ) + .toBe("0px"); + await rootLink.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( From 9387e0b9fd7f47aca45f471fec7aeae10f8b7638 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Sat, 8 Aug 2026 14:36:28 +0100 Subject: [PATCH 16/24] Make thread link hover webview reliable Signed-off-by: kenny lopez --- desktop/src/shared/ui/markdown/MessageLinkPill.tsx | 13 +++++++++++-- desktop/tests/e2e/messaging.spec.ts | 4 ++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 3427b0396b..dd32cb9485 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -1,3 +1,5 @@ +import * as React from "react"; + import { cn } from "@/shared/lib/cn"; import { MENTION_CHIP_BASE_CLASSES, @@ -43,6 +45,7 @@ export function MessageLinkPill({ threadExcerpt, variant = "default", }: MessageLinkPillProps) { + const [isHovered, setIsHovered] = React.useState(false); const channel = channels.find((c) => c.id === link.channelId); const channelLabel = channel?.name ?? "channel"; const isSentFromThread = variant === "sent-from-thread"; @@ -111,12 +114,15 @@ export function MessageLinkPill({