mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(messages): distinguish deleted message links
Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -44,6 +44,9 @@ export function SentFromThreadLine({
|
||||
channels={channels}
|
||||
interactive
|
||||
link={link}
|
||||
onOpenChannel={(targetChannelId) => {
|
||||
void goChannel(targetChannelId);
|
||||
}}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
threadExcerpt={reference.rootExcerpt}
|
||||
variant="sent-from-thread"
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.message-markdown .mention-chip.buzz-link-deleted,
|
||||
.message-markdown .mention-chip.buzz-link-deleted:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.message-markdown .mention-chip.inbox-channel-chip {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground) / 0.82);
|
||||
|
||||
@@ -1272,6 +1272,7 @@ export function createMarkdownComponents(
|
||||
const {
|
||||
channels,
|
||||
imetaByUrl,
|
||||
onOpenChannel,
|
||||
onOpenEntityLink,
|
||||
onOpenMessageLink,
|
||||
onImportSnapshotFromUrl,
|
||||
@@ -1281,10 +1282,6 @@ export function createMarkdownComponents(
|
||||
if (!interactive) {
|
||||
return <span className="font-medium text-current">{children}</span>;
|
||||
}
|
||||
|
||||
// Markdown image-link syntax (`[](href)`) otherwise nests the
|
||||
// image lightbox button inside an anchor. Keep the image as the lightbox
|
||||
// trigger and suppress the parent link activation for block media.
|
||||
if (hasBlockMedia(React.Children.toArray(children))) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1359,6 +1356,7 @@ export function createMarkdownComponents(
|
||||
channels={channels}
|
||||
interactive={interactive}
|
||||
link={messageLinkTarget.link}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
@@ -1682,7 +1680,8 @@ export function createMarkdownComponents(
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { channels, onOpenMessageLink } = useMarkdownRuntime();
|
||||
const { channels, onOpenChannel, onOpenMessageLink } =
|
||||
useMarkdownRuntime();
|
||||
const href = String(children ?? "");
|
||||
const parsed = parseMessageLink(href);
|
||||
if (!parsed.ok) {
|
||||
@@ -1694,6 +1693,7 @@ export function createMarkdownComponents(
|
||||
channels={channels}
|
||||
interactive={interactive}
|
||||
link={parsed.value}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { channelTooltipFooter } from "./ChannelDeepLink.tsx";
|
||||
|
||||
const channel = {
|
||||
id: "channel-id",
|
||||
name: "history",
|
||||
channelType: "forum",
|
||||
visibility: "private",
|
||||
description: "",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
memberCount: 0,
|
||||
memberPubkeys: [],
|
||||
lastMessageAt: null,
|
||||
archivedAt: "2026-08-17T00:00:00Z",
|
||||
participants: [],
|
||||
participantPubkeys: [],
|
||||
isMember: true,
|
||||
ttlSeconds: null,
|
||||
ttlDeadline: null,
|
||||
};
|
||||
|
||||
test("channelTooltipFooter adds archived status without changing existing metadata", () => {
|
||||
assert.equal(
|
||||
channelTooltipFooter(channel),
|
||||
"Private channel · Forum · Archived",
|
||||
);
|
||||
assert.equal(
|
||||
channelTooltipFooter({ ...channel, archivedAt: null }),
|
||||
"Private channel · Forum",
|
||||
);
|
||||
});
|
||||
@@ -35,10 +35,11 @@ function formatChannelActivity(timestamp: string): string | null {
|
||||
return `Active ${Math.floor(elapsedDays / 7)}w ago`;
|
||||
}
|
||||
|
||||
function channelTooltipFooter(channel: Channel) {
|
||||
export function channelTooltipFooter(channel: Channel) {
|
||||
const details = [
|
||||
channel.visibility === "private" ? "Private channel" : "Public channel",
|
||||
channel.channelType === "forum" ? "Forum" : null,
|
||||
channel.archivedAt ? "Archived" : null,
|
||||
channel.lastMessageAt ? formatChannelActivity(channel.lastMessageAt) : null,
|
||||
];
|
||||
return details.filter(Boolean).join(" · ");
|
||||
@@ -136,6 +137,7 @@ export function ChannelDeepLinkAnchor({
|
||||
href={href}
|
||||
interactive={interactive}
|
||||
link={messageLink}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
@@ -184,6 +186,7 @@ export function MarkdownChannelDeepLink({
|
||||
href={href}
|
||||
interactive={interactive}
|
||||
link={messageLink}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenMessageLink={onOpenMessageLink}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,16 @@ function MessageLinkMetadataTooltip({
|
||||
footer: string;
|
||||
metadata: ReturnType<typeof useMessageLinkMetadata>;
|
||||
}) {
|
||||
if (metadata.state.kind === "deleted") {
|
||||
return (
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side="top">Message deleted</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) {
|
||||
return children;
|
||||
}
|
||||
@@ -105,6 +115,7 @@ export function MessageLinkPill({
|
||||
href,
|
||||
interactive,
|
||||
link,
|
||||
onOpenChannel,
|
||||
onOpenMessageLink,
|
||||
threadExcerpt,
|
||||
variant = "default",
|
||||
@@ -118,6 +129,7 @@ export function MessageLinkPill({
|
||||
const shouldLoadMetadata =
|
||||
channelReadable && interactive && variant === "default";
|
||||
const metadata = useMessageLinkMetadata(link, shouldLoadMetadata);
|
||||
const isDeleted = metadata.state.kind === "deleted";
|
||||
const metadataPending =
|
||||
shouldLoadMetadata &&
|
||||
(metadata.state.kind === "idle" || metadata.state.kind === "loading");
|
||||
@@ -150,10 +162,18 @@ export function MessageLinkPill({
|
||||
data-message-link=""
|
||||
href={permalink}
|
||||
icon="message"
|
||||
aria-label={`Open message in channel ${channelLabel}`}
|
||||
className="max-w-64"
|
||||
aria-label={
|
||||
isDeleted
|
||||
? `Deleted message in channel ${channelLabel}`
|
||||
: `Open message in channel ${channelLabel}`
|
||||
}
|
||||
className={cn("max-w-64", isDeleted && "buzz-link-deleted")}
|
||||
interactive={interactive}
|
||||
onOpenLink={() => {
|
||||
if (isDeleted) {
|
||||
onOpenChannel(link.channelId);
|
||||
return;
|
||||
}
|
||||
onOpenMessageLink(link);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ export type MessageLinkPillProps = {
|
||||
href?: string;
|
||||
interactive: boolean;
|
||||
link: ParsedMessageLink;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenMessageLink: (link: ParsedMessageLink) => void;
|
||||
threadExcerpt?: string | null;
|
||||
variant?: "default" | "sent-from-thread";
|
||||
|
||||
@@ -7,8 +7,18 @@ import { getUserProfile } from "@/shared/api/tauriProfiles";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
const MESSAGE_METADATA_RETRY_DELAY_MS = 750;
|
||||
const EVENT_NOT_FOUND_MESSAGE = "event not found";
|
||||
const PREVIEWABLE_MESSAGE_KINDS = new Set([9, 40002, 45001, 45003]);
|
||||
|
||||
function isEventNotFoundError(error: unknown): boolean {
|
||||
if (typeof error === "string") {
|
||||
return error.includes(EVENT_NOT_FOUND_MESSAGE);
|
||||
}
|
||||
return (
|
||||
error instanceof Error && error.message.includes(EVENT_NOT_FOUND_MESSAGE)
|
||||
);
|
||||
}
|
||||
|
||||
function waitForMessageMetadataRetry(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS);
|
||||
@@ -18,7 +28,8 @@ function waitForMessageMetadataRetry(): Promise<void> {
|
||||
async function getMessageLinkEvent(messageId: string) {
|
||||
try {
|
||||
return await getEventById(messageId);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isEventNotFoundError(error)) throw error;
|
||||
await waitForMessageMetadataRetry();
|
||||
return getEventById(messageId);
|
||||
}
|
||||
@@ -33,10 +44,12 @@ type MessageLinkMetadataState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "loading" }
|
||||
| ({ kind: "ready" } & MessageLinkMetadata)
|
||||
| { kind: "deleted" }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
type CachedMessageLinkMetadata =
|
||||
| ({ kind: "ready" } & MessageLinkMetadata)
|
||||
| { kind: "deleted" }
|
||||
| { kind: "unavailable" };
|
||||
|
||||
const metadataCache = new Map<string, Promise<CachedMessageLinkMetadata>>();
|
||||
@@ -71,7 +84,11 @@ function fetchMetadata(
|
||||
snippet: summarizeMessageLinkContent(event.content),
|
||||
};
|
||||
})
|
||||
.catch(() => ({ kind: "unavailable" }) as const);
|
||||
.catch((error) =>
|
||||
isEventNotFoundError(error)
|
||||
? ({ kind: "deleted" } as const)
|
||||
: ({ kind: "unavailable" } as const),
|
||||
);
|
||||
metadataCache.set(key, request);
|
||||
void request.then((result) => {
|
||||
if (result.kind === "unavailable" && metadataCache.get(key) === request) {
|
||||
|
||||
@@ -9631,12 +9631,12 @@ async function resolveGetEvent(
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
// Allow test specs to mark specific event IDs as definitively deleted.
|
||||
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
|
||||
throw new Error("event not found");
|
||||
}
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
// Allow test specs to mark specific event IDs as definitively deleted.
|
||||
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
|
||||
throw new Error("event not found");
|
||||
}
|
||||
const knownEvents: RelayEvent[] = [
|
||||
...Array.from(mockMessages.values()).flat(),
|
||||
{
|
||||
|
||||
@@ -379,6 +379,92 @@ test("reopening the same entity link reapplies its workspace state", async ({
|
||||
await expect(issueHeading).toBeVisible();
|
||||
});
|
||||
|
||||
test("definitively deleted message links open their channel and remain copyable", async ({
|
||||
page,
|
||||
}) => {
|
||||
const deletedMessageId = "d".repeat(64);
|
||||
const channelId = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
const link = `buzz://message?channel=${channelId}&id=${deletedMessageId}`;
|
||||
await installMockBridge(page, { deletedEventIds: [deletedMessageId] });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page
|
||||
.getByTestId("message-input")
|
||||
.fill(`Deleted link \`reference\` ${link}`);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const linkMessage = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "Deleted link" })
|
||||
.last();
|
||||
const deletedLink = linkMessage.getByLabel(
|
||||
"Deleted message in channel random",
|
||||
);
|
||||
await expect(deletedLink).toHaveText("random");
|
||||
await expect(deletedLink).toHaveClass(/buzz-link-deleted/);
|
||||
const inlineCode = linkMessage
|
||||
.locator("code")
|
||||
.filter({ hasText: "reference" });
|
||||
await expect(inlineCode).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [deletedStyles, codeStyles] = await Promise.all([
|
||||
deletedLink.evaluate((element) => {
|
||||
const styles = getComputedStyle(element);
|
||||
return [styles.backgroundColor, styles.color];
|
||||
}),
|
||||
inlineCode.evaluate((element) => {
|
||||
const styles = getComputedStyle(element);
|
||||
return [styles.backgroundColor, styles.color];
|
||||
}),
|
||||
]);
|
||||
return JSON.stringify(deletedStyles) === JSON.stringify(codeStyles);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON");
|
||||
const deletedColors = await deletedLink.evaluate((element) => {
|
||||
const styles = getComputedStyle(element);
|
||||
return [styles.backgroundColor, styles.color];
|
||||
});
|
||||
await deletedLink.hover();
|
||||
await expect
|
||||
.poll(() =>
|
||||
deletedLink.evaluate((element) => {
|
||||
const styles = getComputedStyle(element);
|
||||
return [styles.backgroundColor, styles.color];
|
||||
}),
|
||||
)
|
||||
.toEqual(deletedColors);
|
||||
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Message deleted");
|
||||
await deletedLink.click({ button: "right" });
|
||||
const linkMenu = page.locator("[data-buzz-link-context-menu]");
|
||||
await expect(
|
||||
linkMenu.getByRole("button", { name: "Open link" }),
|
||||
).toBeVisible();
|
||||
await linkMenu.getByRole("button", { name: "Copy link" }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
return (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_COMMAND_LOG__?: Array<{
|
||||
command: string;
|
||||
payload: { text?: string };
|
||||
}>;
|
||||
}
|
||||
).__BUZZ_E2E_COMMAND_LOG__?.findLast(
|
||||
({ command }) => command === "copy_text_to_clipboard",
|
||||
)?.payload.text;
|
||||
}),
|
||||
)
|
||||
.toBe(link);
|
||||
|
||||
await deletedLink.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect(page).toHaveURL(new RegExp(`#/channels/${channelId}$`));
|
||||
});
|
||||
|
||||
test("cold-start entity links drain after the React listener mounts", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user