Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8cc27ba
Add Send to channel for thread messages
klopez4212 Aug 8, 2026
cccf293
Merge remote-tracking branch 'origin/main' into kennylopez-send-to-ch…
klopez4212 Aug 8, 2026
dacdf90
Restore shared thread chip styling
klopez4212 Aug 8, 2026
4fb733e
fix(desktop): wait for thread messages before sharing
klopez4212 Aug 8, 2026
8dff13d
Preserve shared message semantics
klopez4212 Aug 8, 2026
394165b
Refine shared thread provenance
klopez4212 Aug 8, 2026
530f235
Render thread provenance as a link
klopez4212 Aug 8, 2026
bbd4f85
Start shared posts as new message groups
klopez4212 Aug 8, 2026
2cc6ee2
Render thread links in the composer
klopez4212 Aug 8, 2026
98cc2b8
Align thread links with Inbox chips
klopez4212 Aug 8, 2026
19baa51
Underline thread source links on hover
klopez4212 Aug 8, 2026
0fbf9bb
Match thread source links to Inbox metadata
klopez4212 Aug 8, 2026
3115a60
Use read-state thread source styling
klopez4212 Aug 8, 2026
47f8eed
Refine thread source link styling
klopez4212 Aug 8, 2026
e37d554
Increase thread source emphasis
klopez4212 Aug 8, 2026
3514b71
Limit thread link underline to text
klopez4212 Aug 8, 2026
9387e0b
Make thread link hover webview reliable
klopez4212 Aug 8, 2026
eab77c9
Keep native thread underline visible
klopez4212 Aug 8, 2026
0030d90
Harden send-to-channel edge cases
klopez4212 Aug 8, 2026
caec1b5
Preserve edited mention identities
klopez4212 Aug 10, 2026
ea757a4
Merge origin/main into kennylopez-send-to-channel
klopez4212 Aug 10, 2026
a9ae085
Preserve mentions in partial edits
Aug 10, 2026
fea3292
Stabilize thread sharing edit metadata
Aug 10, 2026
c7d5de9
fix(desktop): preserve edit mention identities
Aug 10, 2026
d1268ab
fix(desktop): preserve mentions in partial edits
Aug 10, 2026
cbad00c
Fix thread sharing review edge cases
wesbillman Aug 10, 2026
7811d5c
Merge origin/main into kennylopez-send-to-channel
wesbillman Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
onSendMessage,
onSendToChannel,
onSendVideoReviewComment,
onSendThreadReply,
onThreadScrollTargetResolved,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ export type ChannelPaneProps = {
mediaTags?: string[][],
channelId?: string | null,
) => Promise<void>;
onSendToChannel: (
message: TimelineMessage,
threadRoot: TimelineMessage,
channelId: string,
) => Promise<void>;
onSendVideoReviewComment?: (
message: TimelineMessage,
content: string,
Expand Down
7 changes: 4 additions & 3 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -495,6 +494,7 @@ export function ChannelScreen({
handleExpandThreadReplies,
handleOpenThread,
handleSendMessage,
handleSendToChannel,
handleSendThreadReply,
handleSelectThreadReplyTarget,
handleToggleReaction,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -940,6 +940,7 @@ export function ChannelScreen({
onOpenThread={handleOpenThreadAndCloseAgentSession}
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
onSendMessage={handleSendMessage}
onSendToChannel={handleSendToChannel}
onSendVideoReviewComment={effectiveSendVideoReviewComment}
onSendThreadReply={handleSendThreadReply}
onThreadScrollTargetResolved={
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/features/channels/useChannelPaneHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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,
Comment thread
klopez4212 marked this conversation as resolved.
});
},
[],
);

const handleSendThreadReply = React.useCallback(
async (
content: string,
Expand Down Expand Up @@ -376,6 +393,7 @@ export function useChannelPaneHandlers({
handleExpandThreadReplies,
handleOpenThread,
handleSendMessage,
handleSendToChannel,
handleSendThreadReply,
handleSelectThreadReplyTarget,
handleToggleReaction,
Expand Down
34 changes: 33 additions & 1 deletion desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[][] = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -413,6 +421,8 @@ export function useSendMessageMutation(
mentionPubkeys?: string[];
parentEventId?: string | null;
mediaTags?: string[][];
sentFromThreadRootId?: string | null;
sentFromThreadRootExcerpt?: string | null;
},
MessageQueryContext | undefined
>({
Expand All @@ -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
Expand Down Expand Up @@ -465,6 +477,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
Expand Down Expand Up @@ -537,7 +555,17 @@ export function useSendMessageMutation(
effectiveChannel.id,
content,
recipientPubkeys,
mentionTags,
[
...mentionTags,
...(sentFromThreadRootId
? [
buildSentFromThreadTag(
sentFromThreadRootId,
sentFromThreadRootExcerpt,
),
]
: []),
],
);
},
onMutate: async ({
Expand All @@ -547,6 +575,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
Expand Down Expand Up @@ -582,6 +612,8 @@ export function useSendMessageMutation(
mentionPubkeys ?? [],
parentEventId ?? null,
mediaTags ?? [],
sentFromThreadRootId ?? null,
sentFromThreadRootExcerpt ?? null,
);

const nextWindow = mergeLiveChannelWindowEvent(
Expand Down
48 changes: 48 additions & 0 deletions desktop/src/features/messages/lib/canSendToChannel.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
21 changes: 21 additions & 0 deletions desktop/src/features/messages/lib/canSendToChannel.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
klopez4212 marked this conversation as resolved.
Outdated
}

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.");
}
}
69 changes: 69 additions & 0 deletions desktop/src/features/messages/lib/sentFromThread.test.mjs
Original file line number Diff line number Diff line change
@@ -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)) ?? "", /…$/);
});
Loading
Loading