Merge remote-tracking branch 'origin/main' into wrench/pr6590-fixes

Co-authored-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
Signed-off-by: Wrench <0eabe6ea5758c1e4c5b68cea4ac42b32c479072883cb28da8110b4a47c32b9a1@buzz.block.builderlab.xyz>
This commit is contained in:
Wrench
2026-08-25 22:41:42 -04:00
65 changed files with 2956 additions and 211 deletions

View File

@@ -82,11 +82,14 @@ type EmojiPickerProps = {
autoFocus?: boolean;
/** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */
onSelect: (emoji: string) => void;
/** Number of emoji columns. Defaults to the compact picker used elsewhere. */
perLine?: number;
};
export const EmojiPicker = React.memo(function EmojiPicker({
autoFocus = false,
onSelect,
perLine = 8,
}: EmojiPickerProps) {
const customEmoji = useCustomEmoji();
const custom = React.useMemo(
@@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({
onSelect(value);
}
}}
perLine={8}
perLine={perLine}
previewPosition="bottom"
set="native"
skinTonePosition="search"

View File

@@ -610,6 +610,7 @@ export function ForumComposer({
) : undefined
}
formattingDisabled={Boolean(disabled || isSubmissionPending)}
gifMediaController={media}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={Boolean(isSending || isSubmissionPending)}

View File

@@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
klipyGifAttachment,
klipyGifFilename,
normalizeKlipyGifs,
relayKlipyCapability,
} from "./api.ts";
const GIF_ASSET = {
url: "https://static.klipy.com/example.gif",
width: 640,
height: 360,
size: 42,
};
test("normalizeKlipyGifs selects a compact preview and medium GIF", () => {
const [gif] = normalizeKlipyGifs([
{
id: 7,
title: " Ship it ",
slug: "ship-it",
type: "gif",
file: {
md: { gif: GIF_ASSET },
sm: {
webp: {
url: "https://static.klipy.com/preview.webp",
width: 220,
height: 124,
size: 12,
},
},
},
},
]);
assert.equal(gif.title, "Ship it");
assert.equal(gif.original.url, GIF_ASSET.url);
assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp");
assert.equal(gif.poster, null);
});
test("normalizeKlipyGifs carries a static jpg poster when present", () => {
const [gif] = normalizeKlipyGifs([
{
id: 8,
title: "Static poster",
slug: "static-poster",
type: "gif",
file: {
md: { gif: GIF_ASSET },
sm: {
jpg: {
url: "https://static.klipy.com/poster.jpg",
width: 220,
height: 124,
size: 8,
},
},
},
},
]);
assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg");
});
test("normalizeKlipyGifs omits ads and malformed file records", () => {
const gifs = normalizeKlipyGifs([
{ id: 1, slug: "ad", type: "ad" },
{ id: 2, slug: "missing", type: "gif", file: {} },
]);
assert.deepEqual(gifs, []);
});
test("relayKlipyCapability requires safe search and share endpoints", () => {
assert.deepEqual(
relayKlipyCapability({
gif: {
provider: "klipy",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["nip-er", "buzz-gif"],
}),
{ searchPath: "/gifs/search", sharePath: "/gifs/share" },
);
assert.equal(relayKlipyCapability({}), null);
assert.equal(
relayKlipyCapability({
gif: {
provider: "another",
search: "/gifs/search",
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
null,
);
assert.equal(
relayKlipyCapability({
gif: { provider: "klipy", search: "/gifs/search" },
supported_extensions: ["buzz-gif"],
}),
null,
);
for (const path of [
"https://attacker.example/search",
"//attacker.example/search",
"/\\attacker.example/search",
"/%5c%5cattacker.example/search",
"/gifs/../admin",
"/gifs/%2e%2e/admin",
"/gifs/search?redirect=https://attacker.example",
"/gifs/search#fragment",
]) {
assert.equal(
relayKlipyCapability({
gif: {
provider: "klipy",
search: path,
share: "/gifs/share",
},
supported_extensions: ["buzz-gif"],
}),
null,
);
assert.equal(
relayKlipyCapability({
gif: {
provider: "klipy",
search: "/gifs/search",
share: path,
},
supported_extensions: ["buzz-gif"],
}),
null,
);
}
});
test("klipyGifFilename sanitizes provider slugs", () => {
const gif = {
id: 1,
original: GIF_ASSET,
poster: null,
preview: GIF_ASSET,
slug: " That's a wrap! ",
title: "That's a wrap",
};
const filename = klipyGifFilename(gif);
assert.equal(filename, "that-s-a-wrap.gif");
});
test("klipyGifAttachment references KLIPY media without an uploaded copy", () => {
const attachment = klipyGifAttachment({
id: 1,
original: GIF_ASSET,
poster: null,
preview: GIF_ASSET,
slug: "ship-it",
title: "Ship it",
});
assert.deepEqual(attachment, {
dim: "640x360",
displayLabel: "Ship it",
filename: "ship-it.gif",
sha256: "",
size: 42,
type: "image/gif",
uploaded: 0,
url: GIF_ASSET.url,
});
});

View File

@@ -0,0 +1,185 @@
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
type KlipyAsset = {
height?: number;
size?: number;
url?: string;
width?: number;
};
type KlipyFileSet = {
gif?: KlipyAsset;
jpg?: KlipyAsset;
webp?: KlipyAsset;
};
type KlipyRawGif = {
file?: {
hd?: KlipyFileSet;
md?: KlipyFileSet;
sm?: KlipyFileSet;
xs?: KlipyFileSet;
};
id?: number;
slug?: string;
title?: string;
type?: string;
};
export type KlipyResponse = {
data?: {
data?: KlipyRawGif[];
};
result?: boolean;
};
export type KlipyGif = {
id: number | null;
original: Required<KlipyAsset>;
/**
* Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg`
* asset. Rendered in place of the animated preview under
* `prefers-reduced-motion: reduce`.
*/
poster: Required<KlipyAsset> | null;
preview: Required<KlipyAsset>;
slug: string;
title: string;
};
export type RelayGifSearchInfo = {
gif?: {
provider?: string;
search?: string;
share?: string;
};
supported_extensions?: string[];
};
export type RelayKlipyCapability = {
searchPath: string;
sharePath: string;
};
function safeRelayPath(path: unknown): path is string {
return (
typeof path === "string" &&
path.startsWith("/") &&
!path.startsWith("//") &&
!path.includes("\\") &&
!path.includes("%") &&
!path.includes("?") &&
!path.includes("#") &&
!path.split("/").some((segment) => segment === "." || segment === "..")
);
}
/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */
export function relayKlipyCapability(
info: RelayGifSearchInfo,
): RelayKlipyCapability | null {
const searchPath = info.gif?.search;
const sharePath = info.gif?.share;
if (
info.supported_extensions?.includes("buzz-gif") === true &&
info.gif?.provider === "klipy" &&
safeRelayPath(searchPath) &&
safeRelayPath(sharePath)
) {
return { searchPath, sharePath };
}
return null;
}
function isCompleteAsset(
asset: KlipyAsset | undefined,
): asset is Required<KlipyAsset> {
return (
typeof asset?.url === "string" &&
asset.url.length > 0 &&
typeof asset.width === "number" &&
typeof asset.height === "number" &&
typeof asset.size === "number"
);
}
function firstCompleteAsset(
...assets: Array<KlipyAsset | undefined>
): Required<KlipyAsset> | null {
return assets.find(isCompleteAsset) ?? null;
}
/**
* Normalize KLIPY's mixed media response to GIF-only results. The API can
* interleave ad/content records without a file payload; those are intentionally
* omitted until Buzz has an explicit third-party ad surface.
*/
export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] {
const gifs: KlipyGif[] = [];
for (const item of items) {
if (item.type !== "gif" || !item.file || !item.slug) continue;
const original = firstCompleteAsset(
item.file.md?.gif,
item.file.hd?.gif,
item.file.sm?.gif,
item.file.xs?.gif,
);
const preview = firstCompleteAsset(
item.file.sm?.webp,
item.file.sm?.gif,
item.file.xs?.webp,
item.file.xs?.gif,
item.file.md?.webp,
original ?? undefined,
);
if (!original || !preview) continue;
const poster = firstCompleteAsset(
item.file.sm?.jpg,
item.file.xs?.jpg,
item.file.md?.jpg,
item.file.hd?.jpg,
);
gifs.push({
id: item.id ?? null,
original,
poster,
preview,
slug: item.slug,
title: item.title?.trim() || "GIF",
});
}
return gifs;
}
export function klipyGifFilename(gif: KlipyGif): string {
const safeSlug = gif.slug
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return `${safeSlug || "klipy-gif"}.gif`;
}
/**
* Represent a selected KLIPY GIF as externally hosted media. The empty hash
* marks it as content-only media: the outgoing builder appends the image URL
* to the message body but deliberately omits an imeta tag, since Buzz relays
* only accept verified local `/media/` entries in imeta.
*/
export function klipyGifAttachment(gif: KlipyGif): ImetaMedia {
return {
dim: `${gif.original.width}x${gif.original.height}`,
displayLabel: gif.title,
filename: klipyGifFilename(gif),
sha256: "",
size: gif.original.size,
type: "image/gif",
uploaded: 0,
url: gif.original.url,
};
}

View File

@@ -0,0 +1,142 @@
import {
type KlipyGif,
type KlipyResponse,
normalizeKlipyGifs,
relayKlipyCapability,
type RelayKlipyCapability,
type RelayGifSearchInfo,
} from "@/features/gifs/api";
import { relayHttpFromWs } from "@/shared/api/inviteHelpers";
import { signRelayEvent } from "@/shared/api/tauri";
const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:";
const NIP98_KIND = 27235;
function customerId(relayUrl: string): string {
if (typeof window === "undefined") return globalThis.crypto.randomUUID();
try {
const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`;
const existing = window.localStorage.getItem(storageKey);
if (existing) return existing;
const created = globalThis.crypto.randomUUID();
window.localStorage.setItem(storageKey, created);
return created;
} catch {
// Storage can be unavailable in hardened webviews. Prefer an ephemeral ID
// over a process-wide fallback that would correlate unrelated relays.
return globalThis.crypto.randomUUID();
}
}
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
async function nip98PostHeader(url: string, body: string): Promise<string> {
const authEvent = await signRelayEvent({
kind: NIP98_KIND,
content: "",
tags: [
["u", url],
["method", "POST"],
["payload", await sha256Hex(body)],
["nonce", crypto.randomUUID()],
],
});
return `Nostr ${btoa(JSON.stringify(authEvent))}`;
}
const FRIENDLY_GIF_ERRORS: Record<string, string> = {
relay_membership_required: "Join this community to search GIFs.",
};
function gifErrorMessage(error: string | undefined, status: number): string {
if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error];
return error || `GIF request failed (${status})`;
}
async function relayPost<T>(
relayUrl: string,
path: string,
payload: Record<string, unknown>,
signal?: AbortSignal,
): Promise<T> {
const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`;
const body = JSON.stringify(payload);
const response = await fetch(url, {
body,
headers: {
Authorization: await nip98PostHeader(url, body),
"Content-Type": "application/json",
},
method: "POST",
signal,
});
if (!response.ok) {
const json = (await response.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(gifErrorMessage(json.error, response.status));
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
/** The selected relay's advertised KLIPY endpoints, when supported. */
export async function relayKlipyEndpoints(
relayUrl: string,
signal?: AbortSignal,
): Promise<RelayKlipyCapability | null> {
const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`;
const response = await fetch(url, {
headers: { Accept: "application/nostr+json" },
signal,
});
if (!response.ok)
throw new Error(`Could not read relay capabilities (${response.status})`);
const info = (await response.json()) as RelayGifSearchInfo;
return relayKlipyCapability(info);
}
/** Search KLIPY through the selected relay without exposing its provider key. */
export async function fetchKlipyGifs(
relayUrl: string,
searchPath: string,
query: string,
signal?: AbortSignal,
): Promise<KlipyGif[]> {
const response = await relayPost<KlipyResponse>(
relayUrl,
searchPath,
{
customer_id: customerId(relayUrl),
locale: navigator.language || "en-US",
query: query.trim(),
},
signal,
);
if (response.result === false) {
throw new Error("GIF search failed");
}
return normalizeKlipyGifs(response.data?.data ?? []);
}
/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */
export async function reportKlipyShare(
relayUrl: string,
sharePath: string,
slug: string,
): Promise<void> {
await relayPost<void>(relayUrl, sharePath, {
customer_id: customerId(relayUrl),
slug,
});
}

View File

@@ -0,0 +1,161 @@
import { useQuery } from "@tanstack/react-query";
import { LoaderCircle, Search } from "lucide-react";
import { useReducedMotion } from "motion/react";
import * as React from "react";
import type { KlipyGif } from "@/features/gifs/api";
import { fetchKlipyGifs } from "@/features/gifs/relay";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
type KlipyGifPickerProps = {
onSelect: (gif: KlipyGif) => void;
relayUrl: string;
searchPath: string;
};
const LOADING_SKELETONS = [
"tall-a",
"short-a",
"short-b",
"tall-b",
"short-c",
"short-d",
"tall-c",
"short-e",
"short-f",
"tall-d",
] as const;
export const KlipyGifPicker = React.memo(function KlipyGifPicker({
onSelect,
relayUrl,
searchPath,
}: KlipyGifPickerProps) {
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const prefersReducedMotion = useReducedMotion() ?? false;
React.useEffect(() => {
const timeout = window.setTimeout(
() => setDebouncedSearch(search.trim()),
500,
);
return () => window.clearTimeout(timeout);
}, [search]);
const gifsQuery = useQuery({
queryFn: ({ signal }) =>
fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal),
queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch],
retry: false,
staleTime: 5 * 60 * 1_000,
});
return (
<div className="flex h-[435px] w-[352px] flex-col bg-muted text-foreground">
<div className="p-2.5">
<div className="relative">
<Search
aria-hidden
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="Search KLIPY"
autoFocus
className="h-9 bg-background/75 pl-8 pr-8 hover:bg-background/85 focus-visible:bg-background focus-visible:ring-ring/40"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search KLIPY"
type="search"
value={search}
/>
{gifsQuery.isFetching ? (
<LoaderCircle
aria-hidden
className="absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-muted-foreground"
/>
) : null}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{gifsQuery.isPending ? (
<div className="grid grid-cols-2 gap-1.5">
<span className="sr-only">Loading GIFs</span>
{LOADING_SKELETONS.map((id) => (
<Skeleton
className={id.startsWith("tall") ? "h-28" : "h-20"}
key={id}
/>
))}
</div>
) : gifsQuery.isError ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<p className="text-sm text-muted-foreground">
{gifsQuery.error.message}
</p>
<button
className="text-sm font-medium text-primary hover:underline"
onClick={() => void gifsQuery.refetch()}
type="button"
>
Try again
</button>
</div>
) : gifsQuery.data.length === 0 ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
No GIFs found.
</div>
) : (
<div className="columns-2 gap-1.5" data-testid="klipy-gif-grid">
{gifsQuery.data.map((gif) => {
const staticPoster = prefersReducedMotion ? gif.poster : null;
const showAnimated = !prefersReducedMotion;
return (
<button
aria-label={`Choose ${gif.title}`}
className="mb-1.5 block w-full break-inside-avoid overflow-hidden rounded-lg bg-muted outline-hidden ring-offset-background transition-[filter,transform] hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:scale-[0.98]"
key={gif.slug}
onClick={() => onSelect(gif)}
title={gif.title}
type="button"
>
{showAnimated ? (
<img
alt={gif.title}
className="block h-auto w-full"
height={gif.preview.height}
loading="lazy"
src={gif.preview.url}
width={gif.preview.width}
/>
) : staticPoster ? (
<img
alt={gif.title}
className="block h-auto w-full"
height={staticPoster.height}
loading="lazy"
src={staticPoster.url}
width={staticPoster.width}
/>
) : (
<span
className="flex aspect-video w-full items-center justify-center bg-muted px-2 text-center text-xs text-muted-foreground"
data-testid="klipy-gif-static-placeholder"
>
{gif.title}
</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="border-t border-border/60 px-3 py-2 text-center text-2xs text-muted-foreground">
Powered by KLIPY
</div>
</div>
);
});

View File

@@ -9,25 +9,25 @@ globalThis.localStorage = {
const preference = await import("./autoPinMentionedAgentsPreference.ts");
test("defaults missing and invalid values to keeping mentioned agents pinned", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true);
test("defaults missing and invalid values to one-time agent mentions", () => {
assert.equal(preference.parseKeepMentionedAgentsPinned(null), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false);
assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true);
assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false);
});
test("persists changes to the post-mention pinning preference", () => {
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
preference.setKeepMentionedAgentsPinned(true);
assert.equal(preference.getKeepMentionedAgentsPinned(), true);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"true",
);
preference.setKeepMentionedAgentsPinned(false);
assert.equal(preference.getKeepMentionedAgentsPinned(), false);
assert.equal(
values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY),
"false",
);
});

View File

@@ -2,7 +2,7 @@ import * as React from "react";
export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY =
"buzz.messages.keepMentionedAgentsPinned";
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true;
export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false;
const listeners = new Set<() => void>();
let keepMentionedAgentsPinned = readStoredPreference();

View File

@@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => {
assert.equal(out[0].size, 0);
});
test("buildImetaTags: omits x line when sha256 is empty", () => {
test("buildImetaTags: omits hashless external media entirely", () => {
const tags = buildImetaTags([
{
url: "https://b/a.png",
type: "image/png",
url: "https://static.klipy.com/a.gif",
type: "image/gif",
sha256: "",
size: 1,
uploaded: 0,
},
]);
assert.equal(tags.length, 1);
// No element starts with "x " or "x\t" — no empty x line emitted.
assert.ok(
!tags[0].some((part) => /^x[\s\t]/.test(part)),
`expected no x line, got ${JSON.stringify(tags[0])}`,
);
assert.deepEqual(tags, []);
});
test("buildOutgoingMessage: hashless external media is content-only", () => {
const out = buildOutgoingMessage("", [
{
url: "https://static.klipy.com/a.gif",
type: "image/gif",
sha256: "",
size: 1,
uploaded: 0,
},
]);
assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)");
assert.equal(out.mediaTags, undefined);
});
test("buildImetaTags: omits size line when size is 0", () => {
@@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => {
);
});
test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => {
// Legacy / cross-client entry: only url + m. No x, no size.
test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => {
const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]];
const projected = imetaMediaFromTags(legacyTags);
assert.equal(projected.length, 1);
assert.equal(projected[0].sha256, "");
assert.equal(projected[0].size, 0);
const rebuilt = buildImetaTags(projected);
assert.equal(rebuilt.length, 1);
// Neither "x " nor "size 0" leaked into the rebuilt tag.
assert.ok(
!rebuilt[0].some((part) => /^x[\s\t]/.test(part)),
`expected no x line, got ${JSON.stringify(rebuilt[0])}`,
);
assert.ok(
!rebuilt[0].some((part) => /^size[\s\t]/.test(part)),
`expected no size line, got ${JSON.stringify(rebuilt[0])}`,
);
// url and m survived.
assert.deepEqual(rebuilt[0], [
"imeta",
"url https://b/legacy.png",
"m image/png",
]);
assert.deepEqual(buildImetaTags(projected), []);
});
const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"];

View File

@@ -79,29 +79,31 @@ export function imetaMediaFromTags(
* Shared by the send path (initial post) and the edit path (full new tag set
* on the edit event), so the two stay perfectly symmetric.
*
* `url` and `m` are always emitted (NIP-92's only de-facto required fields;
* `m` carries a fallback in `imetaMediaFromTags`). All other fields are
* conditional — including `x` and `size` — because legacy and cross-client
* imeta entries can land without a sha256 or size, and our relay validator
* rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every
* field except `url` as optional, so dropping them is spec-clean.
* `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries
* without a hash represent external content (for example KLIPY GIFs); their
* markdown URL remains in the message body, but they are omitted from imeta
* because Buzz's relay validator requires a hash-backed local `/media/` path.
* Other fields remain conditional so legacy entries do not emit invalid
* literal `"size 0"` values.
*/
export function buildImetaTags(
imetaMedia: ReadonlyArray<ImetaMedia>,
): string[][] {
return imetaMedia.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
...(d.sha256 ? [`x ${d.sha256}`] : []),
...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []),
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
return imetaMedia
.filter((d) => d.sha256.length > 0)
.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
`x ${d.sha256}`,
...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []),
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
...(d.filename ? [`filename ${d.filename}`] : []),
]);
}
const MEDIA_LINE_RE =
@@ -329,9 +331,11 @@ export function buildOutgoingMessage(
spoiler: spoileredMediaUrls.has(d.url),
});
}
const mediaTags =
pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined;
return { content, mediaTags };
const mediaTags = buildImetaTags(pendingImeta);
return {
content,
mediaTags: mediaTags.length > 0 ? mediaTags : undefined,
};
}
/**

View File

@@ -114,6 +114,10 @@ function storageKey(): string {
: legacyStorageKey();
}
export function getDraftStoreScope(): string {
return storageKey();
}
function legacyStorageKey(): string {
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
}

View File

@@ -13,7 +13,6 @@ import {
X,
} from "lucide-react";
import type { BlobDescriptor } from "@/shared/api/tauri";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import {
@@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties {
}
type MediaAttachmentItemProps = {
attachment: BlobDescriptor;
attachment: ImetaMedia;
isSpoilered: boolean;
onEditSave?: (url: string, bytes: Uint8Array) => Promise<void>;
onRemove: (url: string) => void;
@@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef<
const hash = shortHash(attachment.sha256);
const isVideo = attachment.type.startsWith("video/");
// One accessible name for every control/label in this item. Provider media
// (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary
// uploads keep their historical type-aware `Attachment <hash>` /
// `Video attachment <hash>` name; only genuinely hashless non-provider media
// falls back to a filename.
const mediaLabel =
attachment.displayLabel?.trim() ||
(attachment.sha256
? isVideo
? `Video attachment ${hash}`
: `Attachment ${hash}`
: attachment.filename?.trim() || `Attachment ${hash}`);
const thumbUrl = attachment.thumb
? rewriteRelayUrl(attachment.thumb)
: rewriteRelayUrl(attachment.url);
@@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef<
? rewriteRelayUrl(attachment.thumb)
: undefined;
const canEdit = !isVideo && onEditSave !== undefined;
// Only Buzz-hosted uploads have a content hash. URL-only provider media
// must remain externally hosted instead of being copied into storage by the
// image editor's save path.
const canEdit =
!isVideo && onEditSave !== undefined && attachment.sha256.length === 64;
const canRevert =
!isVideo && onRevert !== undefined && originalUrl !== undefined;
@@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef<
style={composerMediaStyle()}
>
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Trigger asChild>
<div className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70">
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt={`Video attachment ${hash}`}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
<DialogPrimitive.Trigger
aria-label={mediaLabel}
className="h-full w-full cursor-pointer overflow-hidden rounded-2xl border border-border/70"
>
{isVideo ? (
<div className="relative flex h-full w-full items-center justify-center bg-muted text-white">
{videoPosterUrl ? (
<img
src={videoPosterUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-muted/80" />
)}
<div className="absolute inset-0 bg-black/15" />
<div className="absolute flex h-5 w-5 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<Play className="h-4 w-4 fill-white text-white" />
</div>
) : (
<img
src={thumbUrl}
alt={`Attachment ${hash}`}
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</div>
</div>
) : (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover"
/>
)}
{isSpoilered ? (
<div
className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-background/55 text-foreground/70 backdrop-blur-[1px]"
data-composer-media-spoiler=""
>
<HatGlasses className="h-4 w-4" />
</div>
) : null}
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
@@ -387,7 +403,7 @@ const MediaAttachmentItem = React.forwardRef<
onEscapeKeyDown={handleEscapeKeyDown}
>
<DialogPrimitive.Title className="sr-only">
Attachment {hash} preview
{mediaLabel} preview
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
Full-size attachment preview. Press Escape or click outside to
@@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef<
) : null}
{mode === "edit" && !isVideo ? (
<ComposerImageEditor
alt={`Attachment ${hash}`}
alt={mediaLabel}
src={rewriteRelayUrl(attachment.url)}
sourceUrl={attachment.url}
sourceType={attachment.type}
@@ -421,7 +437,7 @@ const MediaAttachmentItem = React.forwardRef<
/>
) : (
<img
alt={`Attachment ${hash}`}
alt=""
className={cn(
"relative max-h-[90vh] max-w-[90vw] rounded-lg object-contain",
isSpoilered && "blur-2xl brightness-75",
@@ -515,7 +531,7 @@ const MediaAttachmentItem = React.forwardRef<
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<button
aria-label="Remove attachment"
aria-label={`Remove ${mediaLabel}`}
type="button"
onClick={() => onRemove(attachment.url)}
className={COMPOSER_MEDIA_REMOVE_CLASS}

View File

@@ -1,13 +1,22 @@
import { SmilePlus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { Images, Smile, SmilePlus } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { klipyGifAttachment, type KlipyGif } from "@/features/gifs/api";
import { relayKlipyEndpoints, reportKlipyShare } from "@/features/gifs/relay";
import { KlipyGifPicker } from "@/features/gifs/ui/KlipyGifPicker";
import { useCommunities } from "@/features/communities/useCommunities";
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type ComposerEmojiPickerProps = {
disabled?: boolean;
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
/** Called when the popover closes without an emoji selection (Escape,
* click-outside). Use this to restore focus to the editor. */
onClose?: () => void;
@@ -17,21 +26,64 @@ type ComposerEmojiPickerProps = {
open: boolean;
};
type ComposerPickerTab = "emoji" | "gifs";
const PICKER_TAB_ORDER: ComposerPickerTab[] = ["emoji", "gifs"];
const ACTIVE_TAB_OUTER_CORNER_RADIUS = "calc(var(--radius-2xl) - 0.375rem)";
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
disabled = false,
gifMediaController,
onClose,
onEmojiSelect,
onOpenChange,
onTriggerMouseDown,
open,
}: ComposerEmojiPickerProps) {
const [pickerTab, setPickerTab] = React.useState<ComposerPickerTab>("emoji");
const shouldReduceMotion = useReducedMotion();
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? "";
const klipyCapabilityQuery = useQuery({
enabled: relayUrl.length > 0,
queryFn: ({ signal }) => relayKlipyEndpoints(relayUrl, signal),
queryKey: ["relay-capability", relayUrl, "klipy"],
retry: 1,
staleTime: 5 * 60 * 1_000,
});
const gifSearchPath = klipyCapabilityQuery.data?.searchPath ?? "";
const gifSharePath = klipyCapabilityQuery.data?.sharePath ?? "";
const gifsAvailable = gifSearchPath.length > 0 && gifSharePath.length > 0;
React.useEffect(() => {
if (!open) setPickerTab("emoji");
}, [open]);
const handleGifSelect = React.useCallback(
(gif: KlipyGif) => {
if (!relayUrl) return;
onOpenChange(false);
gifMediaController.setPendingImeta((current) => [
...current,
klipyGifAttachment(gif),
]);
void reportKlipyShare(relayUrl, gifSharePath, gif.slug).catch(() => {
// Sharing the GIF remains successful if best-effort Recents reporting
// fails; the authenticated relay endpoint keeps provider details hidden.
});
},
[gifMediaController.setPendingImeta, gifSharePath, onOpenChange, relayUrl],
);
return (
<Popover onOpenChange={onOpenChange} open={open}>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
aria-label="Insert emoji"
aria-label={
gifsAvailable ? "Insert emoji or GIF" : "Insert emoji"
}
data-testid="composer-emoji-button"
disabled={disabled}
onMouseDown={onTriggerMouseDown}
@@ -43,7 +95,9 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Insert emoji</TooltipContent>
<TooltipContent>
{gifsAvailable ? "Emoji and GIFs" : "Emoji"}
</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
@@ -64,7 +118,69 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
side="top"
sideOffset={10}
>
<EmojiPicker autoFocus onSelect={onEmojiSelect} />
{gifsAvailable ? (
<Tabs
className="w-[352px] overflow-hidden rounded-2xl bg-muted"
onValueChange={(value) => setPickerTab(value as ComposerPickerTab)}
value={pickerTab}
>
<TabsList className="relative isolate grid h-11 w-full grid-cols-2 overflow-hidden rounded-none border-b border-border/60 bg-muted p-1.5 text-muted-foreground">
<motion.div
animate={{
transform: `translateX(${PICKER_TAB_ORDER.indexOf(pickerTab) * 100}%)`,
}}
aria-hidden="true"
className="absolute bottom-1.5 left-1.5 top-1.5 z-0 rounded-md bg-background shadow"
initial={false}
style={{
borderTopLeftRadius:
pickerTab === "emoji"
? ACTIVE_TAB_OUTER_CORNER_RADIUS
: undefined,
borderTopRightRadius:
pickerTab === "gifs"
? ACTIVE_TAB_OUTER_CORNER_RADIUS
: undefined,
width: "calc((100% - 12px) / 2)",
}}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 0.18,
ease: [0.77, 0, 0.175, 1],
}
}
/>
<TabsTrigger
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="emoji"
>
<Smile aria-hidden className="h-4 w-4" />
Emoji
</TabsTrigger>
<TabsTrigger
className="relative z-10 h-8 gap-1.5 bg-transparent shadow-none transition-colors data-[state=active]:bg-transparent data-[state=active]:shadow-none"
value="gifs"
>
<Images aria-hidden className="h-4 w-4" />
GIFs
</TabsTrigger>
</TabsList>
<TabsContent className="m-0" value="emoji">
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
</TabsContent>
<TabsContent className="m-0" value="gifs">
<KlipyGifPicker
onSelect={handleGifSelect}
relayUrl={relayUrl}
searchPath={gifSearchPath}
/>
</TabsContent>
</Tabs>
) : (
<EmojiPicker autoFocus onSelect={onEmojiSelect} perLine={9} />
)}
</PopoverContent>
</Popover>
);

View File

@@ -170,7 +170,7 @@ function MessageComposerImpl({
media.queuedAttachmentsRef.current.length === 0;
const ownsDropZone = mediaController === undefined;
const backgroundUpload = useBackgroundMediaUpload();
useDraftPersistLifecycle({
const { trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey,
channelId,
loadDraft: drafts.loadDraft,
@@ -272,6 +272,8 @@ function MessageComposerImpl({
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
trackAuthoredContent(text);
contentRef.current = text;
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
if (!isSubmitLockedRef.current && !editTargetRef.current) {
@@ -344,7 +346,7 @@ function MessageComposerImpl({
restoreAddressedAgentMentionsRef.current(pubkeys),
onAddressedAgentsSendFailed: addressPulse.shakeMany,
onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => {
if (newlyPinnedPubkeys.length === 0) return;
if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return;
const sentChannelId = channelId;
if (restoreAddressedAgentMentionsFrameRef.current !== null) {
cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current);
@@ -966,6 +968,7 @@ function MessageComposerImpl({
editor={richText.editor}
extraActions={toolbarExtraActions}
formattingDisabled={composerDisabled}
gifMediaController={media}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending || mentionSendFlow.isPreparingMentionSend}

View File

@@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => {
await handle.unmount();
});
test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => {
const DRAFT_KEY = "chan-clear-race";
setupStore("pubkey-clear-race");
persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "draft text");
trackAuthoredContent("");
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"the authoritative update removes the stale body even while editor reads lag",
);
await handle.unmount();
assert.equal(
loadDraftEntry(DRAFT_KEY),
undefined,
"async settlement must not repersist the deleted body",
);
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "the deleted body must not be restored");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-image";
setupStore("pubkey-clear-caption-image");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]);
let editorContent = "";
let pendingImeta = [];
let spoileredUrls = new Set();
let trackAuthoredContent;
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: pendingImeta,
setPendingImeta: (imeta) => {
pendingImeta = imeta;
},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: (urls) => {
spoileredUrls = urls;
},
spoileredAttachmentUrlsRef: {
get current() {
return spoileredUrls;
},
},
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(pendingImeta[0]?.url, IMG_A.url);
assert.deepEqual([...spoileredUrls], [IMG_A.url]);
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "");
await remounted.unmount();
});
test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => {
const DRAFT_KEY = "chan-clear-caption-file";
setupStore("pubkey-clear-caption-file");
persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []);
const FILE_A = {
file: new File(["report"], "report.pdf", { type: "application/pdf" }),
id: 9,
spoilered: true,
};
let editorContent = "";
let queuedAttachments = [FILE_A];
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
getQueuedAttachments: () => queuedAttachments,
saveQueuedAttachmentsForDraft,
clearQueuedAttachments: () => {
queuedAttachments = [];
},
restoreQueuedAttachments: (attachments) => {
queuedAttachments = attachments;
},
takeQueuedAttachmentsForDraft,
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]);
const handle = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await handle.unmount();
const remounted = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "");
assert.equal(queuedAttachments[0]?.file.name, "report.pdf");
assert.equal(queuedAttachments[0]?.spoilered, true);
await remounted.unmount();
});
test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => {
const DRAFT_KEY = "shared-key";
installFreshLocalStorage();
clearAllDrafts();
initDraftStore("pubkey-a", "wss://relay-a.example");
persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []);
let editorContent = "";
let trackAuthoredContent;
const spoileredRef = { current: new Set() };
function HarnessComposer() {
({ trackAuthoredContent } = useDraftPersistLifecycle({
effectiveDraftKey: DRAFT_KEY,
channelId: DRAFT_KEY,
loadDraft: loadDraftEntry,
persistDraft: persistDraftEntry,
getMentionRefs: () => [],
restoreMentionRefs: () => {},
livePendingImeta: [],
setPendingImeta: () => {},
setContent: (content) => {
editorContent = content;
},
clearContent: () => {
editorContent = "";
},
setSpoileredAttachmentUrls: () => {},
spoileredAttachmentUrlsRef: spoileredRef,
syncComposerContentFromEditor: () => editorContent,
}));
return null;
}
const workspaceA = await mountStrictMode(HarnessComposer);
trackAuthoredContent("");
editorContent = "";
await workspaceA.unmount();
initDraftStore("pubkey-b", "wss://relay-b.example");
persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []);
const workspaceB = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "workspace B");
await workspaceB.unmount();
assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B");
initDraftStore("pubkey-a", "wss://relay-a.example");
const workspaceARemount = await mountStrictMode(HarnessComposer);
assert.equal(editorContent, "", "workspace A stale text must stay cleared");
await workspaceARemount.unmount();
});
test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => {
setupStore("pubkey-switch-files");
const FILE_A = {

View File

@@ -3,6 +3,7 @@ import type { Editor } from "@tiptap/react";
import { AnimatePresence, motion } from "motion/react";
import { ALargeSmall, Paperclip, X } from "lucide-react";
import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload";
import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
@@ -31,6 +32,7 @@ export const MessageComposerToolbar = React.memo(
editor,
extraActions,
formattingDisabled,
gifMediaController,
isEmojiPickerOpen,
isFormattingOpen,
isSending,
@@ -55,6 +57,7 @@ export const MessageComposerToolbar = React.memo(
editor: Editor | null;
extraActions?: React.ReactNode;
formattingDisabled: boolean;
gifMediaController: Pick<MediaUploadController, "setPendingImeta">;
isEmojiPickerOpen: boolean;
isFormattingOpen: boolean;
isSending: boolean;
@@ -210,6 +213,7 @@ export const MessageComposerToolbar = React.memo(
</Tooltip>
<ComposerEmojiPicker
disabled={composerDisabled}
gifMediaController={gifMediaController}
onClose={() => editor?.commands.focus()}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}

View File

@@ -2,9 +2,10 @@ import * as React from "react";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
import type {
DraftMentionRef,
DraftState,
import {
getDraftStoreScope,
type DraftMentionRef,
type DraftState,
} from "@/features/messages/lib/useDrafts";
type UseDraftPersistLifecycleParams = {
@@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = {
syncComposerContentFromEditor: () => string;
};
type UseDraftPersistLifecycleResult = {
/**
* Record the latest authored editor content. Empty content is persisted
* immediately and remains authoritative across composer remounts until a
* later non-empty editor update supersedes it.
*/
trackAuthoredContent: (content: string) => void;
};
const authoritativelyClearedDraftKeys = new Set<string>();
function scopedDraftKey(draftKey: string): string {
return `${getDraftStoreScope()}:${draftKey}`;
}
/**
* Owns the draft-persist lifecycle for `MessageComposer`.
*
@@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({
setSpoileredAttachmentUrls,
spoileredAttachmentUrlsRef,
syncComposerContentFromEditor,
}: UseDraftPersistLifecycleParams): void {
}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult {
const pendingImetaForPersistRef = React.useRef<ImetaMedia[]>([]);
const emptyContentIsAuthoritativeRef = React.useRef(false);
const isRestoringContentRef = React.useRef(false);
const restoredQueuedAttachmentsRef = React.useRef<QueuedMediaAttachment[]>(
[],
);
@@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({
pendingImetaForPersistRef.current = livePendingImeta;
// biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger
React.useEffect(() => {
React.useLayoutEffect(() => {
// The outgoing draft is persisted by the cleanup below, which runs before
// this body on key changes and has the correct outgoing channelId in its
// closure. Do NOT re-persist prevKey here: channelId in this render
@@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({
: [];
}
restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current);
const authoritativeDraftKey = effectiveDraftKey
? scopedDraftKey(effectiveDraftKey)
: null;
const wasAuthoritativelyCleared = authoritativeDraftKey
? authoritativelyClearedDraftKeys.has(authoritativeDraftKey)
: false;
const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined;
emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared;
isRestoringContentRef.current = true;
if (saved) {
setContent(saved.content);
restoreMentionRefs(saved.mentionRefs ?? []);
const restoredContent = wasAuthoritativelyCleared ? "" : saved.content;
setContent(restoredContent);
restoreMentionRefs(
wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []),
);
// Set the persist-snapshot ref SYNCHRONOUSLY before calling the async
// state setter, so the cleanup closure (which may fire before the state
// update commits in React StrictMode's simulate-unmount pass) reads the
@@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({
setPendingImeta([]);
setSpoileredAttachmentUrls(new Set());
}
isRestoringContentRef.current = false;
return () => {
if (effectiveDraftKey) {
@@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({
if (queuedAttachments.length > 0) {
saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments);
}
const content = syncComposerContentFromEditor();
const content = emptyContentIsAuthoritativeRef.current
? ""
: syncComposerContentFromEditor();
persistDraft(
effectiveDraftKey,
content,
@@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({
}
};
}, [effectiveDraftKey]);
const trackAuthoredContent = React.useCallback(
(content: string) => {
if (!effectiveDraftKey || isRestoringContentRef.current) return;
const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey);
if (content.length > 0) {
authoritativelyClearedDraftKeys.delete(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = false;
return;
}
authoritativelyClearedDraftKeys.add(authoritativeDraftKey);
emptyContentIsAuthoritativeRef.current = true;
persistDraft(
effectiveDraftKey,
content,
channelId ?? effectiveDraftKey,
[...pendingImetaForPersistRef.current],
[...spoileredAttachmentUrlsRef.current],
[],
);
},
[channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef],
);
return { trackAuthoredContent };
}