mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -610,6 +610,7 @@ export function ForumComposer({
|
||||
) : undefined
|
||||
}
|
||||
formattingDisabled={Boolean(disabled || isSubmissionPending)}
|
||||
gifMediaController={media}
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isFormattingOpen={isFormattingOpen}
|
||||
isSending={Boolean(isSending || isSubmissionPending)}
|
||||
|
||||
179
desktop/src/features/gifs/api.test.mjs
Normal file
179
desktop/src/features/gifs/api.test.mjs
Normal 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,
|
||||
});
|
||||
});
|
||||
185
desktop/src/features/gifs/api.ts
Normal file
185
desktop/src/features/gifs/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
142
desktop/src/features/gifs/relay.ts
Normal file
142
desktop/src/features/gifs/relay.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
161
desktop/src/features/gifs/ui/KlipyGifPicker.tsx
Normal file
161
desktop/src/features/gifs/ui/KlipyGifPicker.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
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"];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,6 +114,10 @@ function storageKey(): string {
|
||||
: legacyStorageKey();
|
||||
}
|
||||
|
||||
export function getDraftStoreScope(): string {
|
||||
return storageKey();
|
||||
}
|
||||
|
||||
function legacyStorageKey(): string {
|
||||
return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user