Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 82 additions & 0 deletions desktop/src/features/messages/lib/messageEmbed.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
canReadMessageEmbedSource,
extractMessageEmbedLinks,
isMatchingMessageEmbedEvent,
messageEmbedExcerpt,
} from "./messageEmbed.ts";

const link = "buzz://message?channel=channel-1&id=event-1";

test("extractMessageEmbedLinks extracts and deduplicates bare links", () => {
assert.deepEqual(extractMessageEmbedLinks(`See ${link}. Again: ${link}`), [
{
channelId: "channel-1",
href: link,
messageId: "event-1",
threadRootId: null,
},
]);
});

test("extractMessageEmbedLinks skips labeled and code links", () => {
assert.deepEqual(
extractMessageEmbedLinks(
`[context](${link}) \`${link}\`\n\`\`\`\n${link}\n\`\`\``,
),
[],
);
});

test("canReadMessageEmbedSource allows joined and open channels only", () => {
assert.equal(canReadMessageEmbedSource(undefined), false);
assert.equal(
canReadMessageEmbedSource({ isMember: false, visibility: "private" }),
false,
);
assert.equal(
canReadMessageEmbedSource({ isMember: true, visibility: "private" }),
true,
);
assert.equal(
canReadMessageEmbedSource({ isMember: false, visibility: "open" }),
true,
);
});

test("isMatchingMessageEmbedEvent requires both requested id and channel h tag", () => {
const event = {
id: "event-1",
pubkey: "author",
created_at: 1,
kind: 9,
tags: [["h", "channel-1"]],
content: "secret",
sig: "sig",
};
assert.equal(
isMatchingMessageEmbedEvent(event, extractMessageEmbedLinks(link)[0]),
true,
);
assert.equal(
isMatchingMessageEmbedEvent(
{ ...event, id: "other" },
extractMessageEmbedLinks(link)[0],
),
false,
);
assert.equal(
isMatchingMessageEmbedEvent(
{ ...event, tags: [["h", "private-other"]] },
extractMessageEmbedLinks(link)[0],
),
false,
);
});

test("messageEmbedExcerpt normalizes whitespace without truncating source text", () => {
assert.equal(messageEmbedExcerpt("hello\n\n world"), "hello world");
assert.equal(messageEmbedExcerpt("x".repeat(500)).length, 500);
});
80 changes: 80 additions & 0 deletions desktop/src/features/messages/lib/messageEmbed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Channel, RelayEvent } from "@/shared/api/types";

import { parseMessageLink, type ParsedMessageLink } from "./messageLink";

const MESSAGE_LINK_PATTERN = /(?:buzz):\/\/message\?[^\s<>"')\]]+/g;
const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/;
const MAX_MESSAGE_EMBEDS = 4;

export type MessageEmbedLink = ParsedMessageLink & { href: string };

function collectCodeRanges(
content: string,
): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
for (const match of content.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~/g)) {
ranges.push({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
});
}
for (const match of content.matchAll(/`[^`\n]*`/g)) {
ranges.push({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
});
}
return ranges;
}

function isInsideRange(
index: number,
ranges: Array<{ start: number; end: number }>,
) {
return ranges.some((range) => index >= range.start && index < range.end);
}

/** Extract only bare message permalinks; authored markdown labels stay labels. */
export function extractMessageEmbedLinks(content: string): MessageEmbedLink[] {
const codeRanges = collectCodeRanges(content);
const links: MessageEmbedLink[] = [];
const seen = new Set<string>();

for (const match of content.matchAll(MESSAGE_LINK_PATTERN)) {
const index = match.index ?? 0;
if (isInsideRange(index, codeRanges)) continue;
// `[label](buzz://…)` is intentionally labeled and must not unfurl.
if (content.slice(Math.max(0, index - 2), index) === "](") continue;

const href = match[0].replace(TRAILING_PUNCTUATION_PATTERN, "");
if (seen.has(href)) continue;
const parsed = parseMessageLink(href);
if (!parsed.ok) continue;

seen.add(href);
links.push({ href, ...parsed.value });
if (links.length === MAX_MESSAGE_EMBEDS) break;
}

return links;
}

export function canReadMessageEmbedSource(
channel: Pick<Channel, "isMember" | "visibility"> | undefined,
): boolean {
return (
channel !== undefined && (channel.isMember || channel.visibility === "open")
);
}

export function isMatchingMessageEmbedEvent(
event: RelayEvent,
link: ParsedMessageLink,
): boolean {
const eventChannelId = event.tags.find((tag) => tag[0] === "h")?.[1];
return event.id === link.messageId && eventChannelId === link.channelId;
}

export function messageEmbedExcerpt(content: string): string {
return content.replace(/\s+/g, " ").trim();
}
14 changes: 7 additions & 7 deletions desktop/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
import { MarkdownTable } from "./markdown/MarkdownTable";
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
import { ProgressiveImage } from "./markdown/ProgressiveImage";
import { MessageEmbedList } from "./markdown/MessageEmbedList";
import { MessageLinkPill } from "./markdown/MessageLinkPill";
import { renderCachedMarkdown } from "./markdown/nodeCache";
import {
Expand Down Expand Up @@ -1854,13 +1855,6 @@ function MarkdownInner({
);
const onOpenMessageLink = React.useCallback(
(link: ParsedMessageLink) => {
// Always route through `goChannel` with `messageId` set: the channel
// route already handles scroll-into-view + highlight via
// `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,
Expand Down Expand Up @@ -1971,6 +1965,12 @@ function MarkdownInner({
<ConfigNudgeCard nudge={configNudge} />
</AttachmentGroup>
) : null}
<MessageEmbedList
channels={channels}
content={content}
interactive={interactive}
onOpenMessageLink={onOpenMessageLink}
/>
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
Expand Down
142 changes: 142 additions & 0 deletions desktop/src/shared/ui/markdown/MessageEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { LockKeyhole } from "lucide-react";

import { useUserProfileQuery } from "@/features/profile/hooks";
import {
canReadMessageEmbedSource,
isMatchingMessageEmbedEvent,
messageEmbedExcerpt,
type MessageEmbedLink,
} from "@/features/messages/lib/messageEmbed";
import { getEventById } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";

export function MessageEmbed({
channel,
link,
onOpen,
}: {
channel: Channel | undefined;
link: MessageEmbedLink;
onOpen: () => void;
}) {
const canRead = canReadMessageEmbedSource(channel);
const eventQuery = useQuery({
queryKey: ["message-embed", link.channelId, link.messageId],
queryFn: () => getEventById(link.messageId),
enabled: canRead,
retry: false,
staleTime: 60_000,
});
const event =
canRead &&
eventQuery.data &&
isMatchingMessageEmbedEvent(eventQuery.data, link)
? eventQuery.data
: null;
// Never resolve an author profile until channel access and event-channel
// integrity have both been established.
const profileQuery = useUserProfileQuery(event?.pubkey);
const profile = profileQuery.data;
const excerpt = event
? messageEmbedExcerpt(event.content) || "Message has no text"
: "";
const [expanded, setExpanded] = useState(false);
const [isClamped, setIsClamped] = useState(false);
const excerptRef = useRef<HTMLSpanElement>(null);

useEffect(() => {
const excerptElement = excerptRef.current;
if (!excerptElement) return;
const updateClampedState = () =>
setIsClamped(excerptElement.scrollHeight > excerptElement.clientHeight);
updateClampedState();
const observer = new ResizeObserver(updateClampedState);
observer.observe(excerptElement);
return () => observer.disconnect();
});

if (!canRead || eventQuery.isError || (eventQuery.data && !event)) {
return (
<div
className="flex w-full max-w-2xl items-center gap-3 border-l-[3px] border-border py-1 pl-3 text-muted-foreground/70"
data-message-embed="unavailable"
>
<LockKeyhole aria-hidden="true" className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">
Private or unavailable message
</span>
</div>
);
}

if (!event) {
return (
<div
className="w-full max-w-2xl animate-pulse border-l-[3px] border-border py-1 pl-3"
data-message-embed="loading"
>
<span className="sr-only">Loading message preview</span>
<div className="mb-1 h-4 w-36 rounded bg-muted" />
<div className="h-4 w-full rounded bg-muted" />
<div className="mt-1 h-4 w-2/3 rounded bg-muted" />
</div>
);
}

const displayName =
profile?.displayName?.trim() || truncatePubkey(event.pubkey);

return (
<div
className="w-full max-w-2xl border-l-[3px] border-border py-1 pl-3"
data-message-embed="resolved"
>
<div className="mb-0.5 flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="shrink-0"
displayName={displayName}
fallbackDelayMs={0}
size="xs"
/>
<span className="min-w-0 truncate text-xs font-semibold text-foreground">
{displayName}
</span>
<span aria-hidden="true" className="text-xs text-muted-foreground">
·
</span>
<button
aria-label={`Open message by ${displayName} in ${channel?.name ?? "channel"}`}
className="min-w-0 truncate text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onOpen}
type="button"
>
#{channel?.name}
</button>
</div>
<span
className={
expanded
? "block text-sm leading-5 text-foreground"
: "line-clamp-3 text-sm leading-5 text-foreground"
}
ref={excerptRef}
>
{excerpt}
</span>
{expanded || isClamped ? (
<button
className="mt-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setExpanded((value) => !value)}
type="button"
>
{expanded ? "Show less" : "Show more"}
</button>
) : null}
</div>
);
}
45 changes: 45 additions & 0 deletions desktop/src/shared/ui/markdown/MessageEmbedList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from "react";

import {
extractMessageEmbedLinks,
type MessageEmbedLink,
} from "@/features/messages/lib/messageEmbed";
import type { ParsedMessageLink } from "@/features/messages/lib/messageLink";
import type { Channel } from "@/shared/api/types";
import { AttachmentGroup } from "@/shared/ui/attachment";

import { MessageEmbed } from "./MessageEmbed";

export function MessageEmbedList({
channels,
content,
interactive,
onOpenMessageLink,
}: {
channels: Channel[];
content: string;
interactive: boolean;
onOpenMessageLink: (link: ParsedMessageLink) => void;
}) {
const links = React.useMemo(
() => (interactive ? extractMessageEmbedLinks(content) : []),
[content, interactive],
);
if (links.length === 0) return null;

return (
<AttachmentGroup
className="w-full max-w-2xl flex-col items-stretch overflow-visible pb-0"
data-message-embed-list=""
>
{links.map((link: MessageEmbedLink) => (
<MessageEmbed
channel={channels.find((channel) => channel.id === link.channelId)}
key={link.href}
link={link}
onOpen={() => onOpenMessageLink(link)}
/>
))}
</AttachmentGroup>
);
}
Loading
Loading