diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index 1d5f309fb5..93f3f85d51 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -70,11 +70,21 @@ pub async fn get_channel_workflows( /// /// The Workflows overview screen previously issued one `get_channel_workflows` /// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N -/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one -/// query with all channel ids returns the same set. Each `WorkflowWire` carries -/// its own `channel_id` (from the event's `h` tag), so the frontend can still -/// group results by channel. Neither this nor the per-channel command sets a -/// `limit`, so batching does not change result completeness. +/// relay POSTs. One HTTP `/query` can carry multiple NIP-01 filters; we send +/// one filter object per channel id (single-value `#h`) instead of one filter +/// with a multi-value `#h`. +/// +/// Buzz's relay historically collapsed multi-value `#h` to the first UUID when +/// building the SQL predicate (`extract_channel_id_from_filter`), so a batched +/// multi-value query silently dropped workflows on every channel after the +/// first — Desktop showed "No workflows yet" while per-channel CLI queries +/// still worked. One filter per id keeps the single round-trip and matches +/// NIP-01 OR semantics even against relays that still collapse multi-value `#h`. +/// +/// Each `WorkflowWire` carries its own `channel_id` (from the event's `h` tag), +/// so the frontend can still group results by channel. Neither this nor the +/// per-channel command sets a `limit`, so batching does not change result +/// completeness. #[tauri::command] pub async fn get_channels_workflows( channel_ids: Vec, @@ -84,14 +94,17 @@ pub async fn get_channels_workflows( return Ok(Vec::new()); } - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [30620], - "#h": channel_ids, - })], - ) - .await?; + let filters: Vec = channel_ids + .into_iter() + .map(|channel_id| { + serde_json::json!({ + "kinds": [30620], + "#h": [channel_id], + }) + }) + .collect(); + + let events = query_relay(&state, &filters).await?; Ok(events.iter().map(workflow_from_event).collect()) } diff --git a/desktop/src/features/communities/hostedCommunityApi.test.mjs b/desktop/src/features/communities/hostedCommunityApi.test.mjs new file mode 100644 index 0000000000..47ccdf4bd6 --- /dev/null +++ b/desktop/src/features/communities/hostedCommunityApi.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + HOSTED_COMMUNITY_SUFFIX, + hostedCommunityRelayUrl, +} from "./hostedCommunityApi.ts"; + +test("hostedCommunityRelayUrl: uses normalized_host when present", () => { + assert.equal( + hostedCommunityRelayUrl({ + normalized_host: "acme.communities.buzz.xyz", + name: "other", + }), + "wss://acme.communities.buzz.xyz", + ); +}); + +test("hostedCommunityRelayUrl: strips accidental scheme on host", () => { + assert.equal( + hostedCommunityRelayUrl({ + normalized_host: "wss://acme.communities.buzz.xyz", + }), + "wss://acme.communities.buzz.xyz", + ); +}); + +test("hostedCommunityRelayUrl: synthesizes from slug when host missing", () => { + assert.equal( + hostedCommunityRelayUrl({ slug: "acme", name: "ignored" }), + `wss://acme.${HOSTED_COMMUNITY_SUFFIX}`, + ); +}); + +test("hostedCommunityRelayUrl: synthesizes from name when slug missing", () => { + assert.equal( + hostedCommunityRelayUrl({ name: "my-team" }), + `wss://my-team.${HOSTED_COMMUNITY_SUFFIX}`, + ); +}); + +test("hostedCommunityRelayUrl: null when no usable label", () => { + assert.equal(hostedCommunityRelayUrl({}), null); + assert.equal(hostedCommunityRelayUrl({ name: "Bad Name" }), null); +}); diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index 16f9f92567..532622a7a2 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -85,9 +85,29 @@ export function hostedCommunityErrorMessage( : message; } +/** + * Prefer Builderlab's `normalized_host`. When create responses omit it (or + * only return name/slug), synthesize the hosted community hostname so the + * post-create connect handoff still has a relay URL. + */ export function hostedCommunityRelayUrl(community: HostedCommunity) { - const host = community.normalized_host?.trim(); - return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; + const rawHost = + community.normalized_host?.trim() || + synthesizeHostedCommunityHost(community); + if (!rawHost) { + return null; + } + return `wss://${rawHost.replace(/^wss?:\/\//, "")}`; +} + +function synthesizeHostedCommunityHost( + community: HostedCommunity, +): string | null { + const label = (community.slug ?? community.name)?.trim().toLowerCase(); + if (!label || !VALID_HOSTED_COMMUNITY_NAME.test(label)) { + return null; + } + return `${label}.${HOSTED_COMMUNITY_SUFFIX}`; } export function getBuilderlabAuth() { diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx index 1152cd8fec..7225648ba1 100644 --- a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -97,6 +97,7 @@ export function HostedCommunityOnboarding({ const account = await loadHostedCommunityAccount(); setIdentity(account.identity); setCommunities(account.communities); + return account; }, []); React.useEffect(() => { @@ -172,13 +173,6 @@ export function HostedCommunityOnboarding({ setAvailability(null); }); - const goBack = () => { - void run("Signing out…", async () => { - await clearBuilderlabAuth(); - onBack(); - }); - }; - const connectIdentity = () => run("Connecting identity…", async () => { const response = await bindBuilderlabIdentity(); @@ -204,6 +198,20 @@ export function HostedCommunityOnboarding({ ); const localNpub = localPubkey ? safeNpub(localPubkey) : null; + const goBack = () => { + // Once signed in with a linked identity, Back returns to the previous + // onboarding page without clearing Builderlab auth — otherwise creators + // lose their owned-community list and land on "Join a community". + if (auth && identity && !identityMismatch) { + onBack(); + return; + } + void run("Signing out…", async () => { + await clearBuilderlabAuth(); + onBack(); + }); + }; + const switchToDeviceIdentity = () => run("Switching identity…", async () => { const released = await deleteBuilderlabIdentity(); @@ -318,7 +326,20 @@ export function HostedCommunityOnboarding({ ), ); } - connect(response.community, true); + const created = response.community; + // Refresh owned list so Connect recovery and Back still see the community + // even if the immediate connect handoff fails. + const account = await loadAccount(); + const refreshed = + account.communities.find( + (community) => + (created.id && community.id === created.id) || + (created.slug && community.slug === created.slug) || + (created.name && community.name === created.name) || + community.slug === normalizedName || + community.name === normalizedName, + ) ?? created; + connect(refreshed, true); }); }; diff --git a/desktop/src/features/messages/lib/collectMentionPubkeys.ts b/desktop/src/features/messages/lib/collectMentionPubkeys.ts new file mode 100644 index 0000000000..ebe34e0f0c --- /dev/null +++ b/desktop/src/features/messages/lib/collectMentionPubkeys.ts @@ -0,0 +1,76 @@ +import { hasMention } from "@/features/messages/lib/hasMention"; +import { extractNip27MentionPubkeys } from "@/features/messages/lib/nip27Mentions"; +import { rewriteMentionsToNip27 } from "@/features/messages/lib/rewriteMentionsToNip27"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type MentionCandidateLike = { + displayName?: string; + isMember?: boolean; + pubkey?: string; +}; + +/** + * Resolve mention pubkeys from `@name` maps/candidates plus NIP-27 + * `nostr:npub1…` URIs already present in the text. + */ +export function collectMentionPubkeys( + text: string, + mentionMap: Iterable, + selectedDisplayNames: Iterable, + mentionCandidates: readonly MentionCandidateLike[], +): string[] { + const pubkeys: string[] = []; + const selected = new Set( + [...selectedDisplayNames].map((name) => name.trim().toLowerCase()), + ); + + for (const [displayName, pubkey] of mentionMap) { + if (hasMention(text, displayName)) { + pubkeys.push(pubkey); + } + } + + for (const candidate of mentionCandidates) { + if (!candidate.pubkey || !candidate.isMember) { + continue; + } + if (pubkeys.includes(candidate.pubkey)) { + continue; + } + const name = candidate.displayName; + if (name && selected.has(name.trim().toLowerCase())) { + continue; + } + if (name && hasMention(text, name)) { + pubkeys.push(candidate.pubkey); + } + } + + for (const pubkey of extractNip27MentionPubkeys(text)) { + if ( + !pubkeys.some( + (existing) => normalizePubkey(existing) === normalizePubkey(pubkey), + ) + ) { + pubkeys.push(pubkey); + } + } + + return [...new Set(pubkeys)]; +} + +/** Rewrite resolved `@name` mentions in `text` to NIP-27 wire URIs. */ +export function buildNip27WireBody( + text: string, + mentionPubkeys: readonly string[], + getDisplayName: (pubkey: string) => string | null, +): string { + const pairs: Array = []; + for (const pubkey of mentionPubkeys) { + const displayName = getDisplayName(pubkey); + if (displayName) { + pairs.push([displayName, pubkey]); + } + } + return rewriteMentionsToNip27(text, pairs); +} diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index 5e5bafd8ad..fef5e23c41 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -20,7 +20,7 @@ function maskRange( * Replace Markdown code with spaces while retaining offsets and line endings. * Handles fenced blocks, four-space/tab-indented lines, and backtick code spans. */ -function maskMarkdownCode(text: string): string { +export function maskMarkdownCode(text: string): string { const chars = text.split(""); const lines: Array<{ start: number; end: number; content: string }> = []; diff --git a/desktop/src/features/messages/lib/nip27Mentions.test.mjs b/desktop/src/features/messages/lib/nip27Mentions.test.mjs new file mode 100644 index 0000000000..2543176ba7 --- /dev/null +++ b/desktop/src/features/messages/lib/nip27Mentions.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { npubEncode } from "nostr-tools/nip19"; + +import { + extractNip27MentionPubkeys, + replaceNip27MentionsForDisplay, +} from "./nip27Mentions.ts"; + +const ALICE = + "3bf0c63fcb93463407afdbb8b86e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e"; +const aliceUri = `nostr:${npubEncode(ALICE)}`; + +test("extractNip27MentionPubkeys: finds npub in prose", () => { + const out = extractNip27MentionPubkeys(`hello ${aliceUri} world`); + assert.deepEqual(out, [ALICE.toLowerCase()]); +}); + +test("extractNip27MentionPubkeys: skips code spans", () => { + const out = extractNip27MentionPubkeys(`\`${aliceUri}\` then ${aliceUri}`); + assert.deepEqual(out, [ALICE.toLowerCase()]); +}); + +test("replaceNip27MentionsForDisplay: maps known pubkeys to @name", () => { + const out = replaceNip27MentionsForDisplay(`hi ${aliceUri}`, { + Alice: ALICE, + }); + assert.equal(out, "hi @Alice"); +}); + +test("replaceNip27MentionsForDisplay: leaves unknown URIs", () => { + const out = replaceNip27MentionsForDisplay(`hi ${aliceUri}`, {}); + assert.equal(out, `hi ${aliceUri}`); +}); diff --git a/desktop/src/features/messages/lib/nip27Mentions.ts b/desktop/src/features/messages/lib/nip27Mentions.ts new file mode 100644 index 0000000000..5c0ab38f5d --- /dev/null +++ b/desktop/src/features/messages/lib/nip27Mentions.ts @@ -0,0 +1,75 @@ +import { decode } from "nostr-tools/nip19"; + +import { maskMarkdownCode } from "@/features/messages/lib/hasMention"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const NIP27_NPUB_RE = /nostr:(npub1[02-9ac-hj-np-z]+)/gi; + +function npubToHex(npub: string): string | null { + try { + const decoded = decode(npub); + if (decoded.type !== "npub") { + return null; + } + if (typeof decoded.data === "string") { + return normalizePubkey(decoded.data); + } + return null; + } catch { + return null; + } +} + +/** + * Extract pubkeys from NIP-27 `nostr:npub1…` URIs in content, skipping + * Markdown code spans/blocks (same masking as `@mention` matching). + */ +export function extractNip27MentionPubkeys(text: string): string[] { + if (!/nostr:npub1/i.test(text)) { + return []; + } + + const masked = maskMarkdownCode(text); + const pubkeys: string[] = []; + const seen = new Set(); + NIP27_NPUB_RE.lastIndex = 0; + for (const match of masked.matchAll(NIP27_NPUB_RE)) { + const hex = npubToHex(match[1]); + if (!hex || seen.has(hex)) { + continue; + } + seen.add(hex); + pubkeys.push(hex); + } + return pubkeys; +} + +/** + * For display, map known NIP-27 URIs back to `@displayName` so existing + * remark mention highlighting keeps working. Unknown URIs are left intact. + */ +export function replaceNip27MentionsForDisplay( + text: string, + mentionPubkeysByName: Readonly> | undefined, +): string { + if (!mentionPubkeysByName || !/nostr:npub1/i.test(text)) { + return text; + } + + const nameByPubkey = new Map(); + for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) { + const normalized = normalizePubkey(pubkey); + if (!nameByPubkey.has(normalized)) { + nameByPubkey.set(normalized, name); + } + } + + return text.replace(NIP27_NPUB_RE, (full, npub: string) => { + const hex = npubToHex(npub); + if (!hex) { + return full; + } + const name = nameByPubkey.get(hex); + return name ? `@${name}` : full; + }); +} diff --git a/desktop/src/features/messages/lib/prepareComposerEditPayload.ts b/desktop/src/features/messages/lib/prepareComposerEditPayload.ts new file mode 100644 index 0000000000..ed018f1843 --- /dev/null +++ b/desktop/src/features/messages/lib/prepareComposerEditPayload.ts @@ -0,0 +1,53 @@ +import { buildNip27WireBody } from "@/features/messages/lib/collectMentionPubkeys"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +/** + * Build the wire body, imeta/emoji tags, and newly-added mention pubkeys for + * a composer edit save. Composer text stays as `@name`; the wire body is + * rewritten to NIP-27 `nostr:npub1…` URIs. + */ +export function prepareComposerEditPayload(input: { + trimmed: string; + previousBody: string; + pendingImeta: ImetaMedia[]; + spoileredAttachmentUrls: ReadonlySet; + customEmoji: CustomEmoji[]; + ownerPubkey: string; + extractMentionPubkeys: (text: string) => string[]; + getMentionDisplayName: (pubkey: string) => string | null; +}): { + finalContent: string; + outgoingTags: string[][]; + addedMentionPubkeys: string[]; +} { + const wireBody = buildNip27WireBody( + input.trimmed, + input.extractMentionPubkeys(input.trimmed), + input.getMentionDisplayName, + ); + const { content: finalContent, mediaTags } = buildOutgoingMessage( + wireBody, + input.pendingImeta, + input.spoileredAttachmentUrls, + ); + // `?? []` preserves edit semantics: a defined-but-empty media set means + // "wipe attachments". + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, input.customEmoji), + ) ?? []; + const addedMentionPubkeys = diffAddedMentionPubkeys( + input.extractMentionPubkeys(input.previousBody), + input.extractMentionPubkeys(input.trimmed), + input.ownerPubkey, + ); + return { finalContent, outgoingTags, addedMentionPubkeys }; +} diff --git a/desktop/src/features/messages/lib/rewriteMentionsToNip27.test.mjs b/desktop/src/features/messages/lib/rewriteMentionsToNip27.test.mjs new file mode 100644 index 0000000000..fa31743df2 --- /dev/null +++ b/desktop/src/features/messages/lib/rewriteMentionsToNip27.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { rewriteMentionsToNip27 } from "./rewriteMentionsToNip27.ts"; + +// Valid hex pubkeys (32 bytes) for npubEncode. +const ALICE = + "3bf0c63fcb93463407afdbb8b86e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e4e2c2e"; +const BOB = + "a1b2c3d4e5f60718293a4b5c6d7e8f901234567890abcdef1234567890abcdef"; + +test("rewriteMentionsToNip27: replaces @name with nostr:npub", () => { + const out = rewriteMentionsToNip27("hey @Alice", [["Alice", ALICE]]); + assert.match(out, /^hey nostr:npub1[02-9ac-hj-np-z]+$/); + assert.equal(out.includes("@Alice"), false); +}); + +test("rewriteMentionsToNip27: leaves unmatched @names alone", () => { + const out = rewriteMentionsToNip27("hey @Alice", []); + assert.equal(out, "hey @Alice"); +}); + +test("rewriteMentionsToNip27: skips code spans", () => { + const out = rewriteMentionsToNip27("run `@Alice` then @Alice", [ + ["Alice", ALICE], + ]); + assert.match(out, /run `@Alice` then nostr:npub1/); +}); + +test("rewriteMentionsToNip27: longer names first", () => { + const out = rewriteMentionsToNip27("hi @John Doe", [ + ["John", BOB], + ["John Doe", ALICE], + ]); + assert.match(out, /^hi nostr:npub1/); + assert.equal(out.includes("@John"), false); +}); + +test("rewriteMentionsToNip27: team expansion rewrites members", () => { + const out = rewriteMentionsToNip27("Launch Team(@Planner @Builder)", [ + ["Planner", ALICE], + ["Builder", BOB], + ]); + assert.equal(out.includes("@Planner"), false); + assert.equal(out.includes("@Builder"), false); + assert.equal((out.match(/nostr:npub1/g) ?? []).length, 2); +}); diff --git a/desktop/src/features/messages/lib/rewriteMentionsToNip27.ts b/desktop/src/features/messages/lib/rewriteMentionsToNip27.ts new file mode 100644 index 0000000000..4856f212d8 --- /dev/null +++ b/desktop/src/features/messages/lib/rewriteMentionsToNip27.ts @@ -0,0 +1,51 @@ +import { getMentionOffset } from "@/features/messages/lib/hasMention"; +import { safeNpub } from "@/shared/lib/nostrUtils"; + +/** + * Rewrite selected `@displayName` mentions in `text` to NIP-27 `nostr:npub1…` + * URIs for the wire event. Composer UX can keep typing `@name`; callers should + * rewrite only at send/edit time. + * + * Longer display names are applied first so multi-word mentions win over + * shorter prefixes. Code spans/blocks are skipped via `getMentionOffset`. + */ +export function rewriteMentionsToNip27( + text: string, + nameToPubkey: Iterable, +): string { + if (!text.includes("@")) { + return text; + } + + const entries = [...nameToPubkey] + .map(([name, pubkey]) => [name.trim(), pubkey.trim()] as const) + .filter(([name, pubkey]) => name.length > 0 && pubkey.length > 0) + .sort((a, b) => b[0].length - a[0].length); + + if (entries.length === 0) { + return text; + } + + let result = text; + for (const [name, pubkey] of entries) { + const npub = safeNpub(pubkey); + if (!npub) { + continue; + } + const uri = `nostr:${npub}`; + const tokenLength = 1 + name.length; + + // Replace every non-code occurrence. Offset is recomputed each pass + // because earlier replacements shift later positions. + for (;;) { + const offset = getMentionOffset(result, name); + if (offset === null) { + break; + } + result = + result.slice(0, offset) + uri + result.slice(offset + tokenLength); + } + } + + return result; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..31d4dfb23a 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -37,6 +37,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; +import { collectMentionPubkeys } from "./collectMentionPubkeys"; import { useDraftMentionRouting } from "./useDraftMentionRouting"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; @@ -792,42 +793,16 @@ export function useMentions( ); const extractMentionPubkeys = React.useCallback( - (text: string): string[] => { - const pubkeys: string[] = []; - const selectedDisplayNames = new Set( + (text: string): string[] => + collectMentionPubkeys( + text, + mentionMapRef.current, [ ...mentionMapRef.current.keys(), ...personaMentionMapRef.current.keys(), - ].map((name) => name.trim().toLowerCase()), - ); - - for (const [displayName, pubkey] of mentionMapRef.current) { - if (hasMention(text, displayName)) { - pubkeys.push(pubkey); - } - } - - for (const candidate of mentionCandidates) { - if (!candidate.pubkey) { - continue; - } - if (!candidate.isMember) { - continue; - } - if (pubkeys.includes(candidate.pubkey)) { - continue; - } - const name = candidate.displayName; - if (name && selectedDisplayNames.has(name.trim().toLowerCase())) { - continue; - } - if (name && hasMention(text, name)) { - pubkeys.push(candidate.pubkey); - } - } - - return [...new Set(pubkeys)]; - }, + ], + mentionCandidates, + ), [mentionCandidates], ); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 8c0c574049..2125104d7f 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -10,12 +10,9 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; @@ -26,7 +23,7 @@ import { useMediaUpload, } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { prepareComposerEditPayload } from "@/features/messages/lib/prepareComposerEditPayload"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -609,36 +606,17 @@ function MessageComposerImpl({ // effective deletion). if (!trimmed && !hasMedia) return; - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, - spoileredAttachmentUrls, - ); - - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); + const { finalContent, outgoingTags, addedMentionPubkeys } = + prepareComposerEditPayload({ + trimmed, + previousBody: editTargetRef.current.body, + pendingImeta: currentPendingImeta, + spoileredAttachmentUrls, + customEmoji, + ownerPubkey: ownerPubkeyRef.current ?? "", + extractMentionPubkeys: extractMentionPubkeysRef.current, + getMentionDisplayName: mentions.getMentionDisplayName, + }); const savedContent = trimmed; const savedImeta = [...currentPendingImeta]; @@ -719,6 +697,7 @@ function MessageComposerImpl({ mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, mentions.clearMentions, + mentions.getMentionDisplayName, richText.clearContent, richText.setContent, setComposerContent, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4..1cf5472540 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -21,6 +21,7 @@ import { mergeOutgoingTags, } from "@/features/messages/lib/imetaMediaMarkdown"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import { buildNip27WireBody } from "@/features/messages/lib/collectMentionPubkeys"; import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; @@ -703,8 +704,13 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( + const wireBody = buildNip27WireBody( trimmed, + pubkeys, + mentions.getMentionDisplayName, + ); + const { content: finalContent, mediaTags } = buildOutgoingMessage( + wireBody, pendingImeta, spoileredAttachmentUrls, ); @@ -773,6 +779,7 @@ export function useMentionSendFlow({ getNonMemberMentionPubkeys, getDmThreadAgentMentionError, mentions.extractMentionPubkeys, + mentions.getMentionDisplayName, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, onPrepareSendChannel, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..db6af1f18a 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -38,6 +38,7 @@ import { computeConfigNudge, selectProseOrNudge, } from "@/shared/lib/computeConfigNudge"; +import { replaceNip27MentionsForDisplay } from "@/features/messages/lib/nip27Mentions"; import { INLINE_CODE_CHIP_CLASS, MENTION_CHIP_BASE_CLASSES, @@ -1913,6 +1914,13 @@ function MarkdownInner({ // the prose node entirely — so processedContent is never rendered and // stripConfigNudgeSentinel would be dead work on that path. + // NIP-27 wire bodies use nostr:npub1…; map known ones back to @name so the + // existing mention highlighter keeps working. + processedContent = replaceNip27MentionsForDisplay( + processedContent, + mentionPubkeysByName, + ); + if (/^(?:\s{2}\n)+/.test(processedContent)) { processedContent = `\u200B${processedContent}`; }