Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ test("GIF capability discovery does not depend on join-policy availability", asy
}
});

test("GIF discovery retries unsupported relays and caches confirmed support", async () => {
let supported = false;
const descriptor = {
supported_extensions: ["buzz-gif"],
gif: { provider: "klipy", search: "/gifs/search" },
};
const h = await harness(() => Response.json(supported ? descriptor : {}));
try {
expect(await (await h.get("gif-info")).json()).toEqual({});
supported = true;
expect(await (await h.get("gif-info")).json()).toEqual(descriptor);
expect(await (await h.get("gif-info")).json()).toEqual(descriptor);
expect(h.calls.map(({ url }) => url)).toEqual([
fixtureRelayUrl,
fixtureRelayUrl,
]);
} finally {
await h.close();
}
});

test("GIF search follows the relay-advertised KLIPY path with signed, bounded input", async () => {
const responseBody = {
result: true,
Expand Down Expand Up @@ -361,6 +382,72 @@ test("queued request mints fresh auth at dispatch after wall time advances", asy
}
});

test("reaction sign and publish preserve kind 7 and reject malformed targets before upstream I/O", async () => {
const h = await harness((call) =>
Response.json({ accepted: true, event_id: call.body.id }),
);
try {
const template = {
...h.event,
kind: 7,
content: ":party:",
tags: [
["h", "c"],
["e", "a".repeat(64)],
["emoji", "party", "https://a.test/party.png"],
],
};
const response = await h.post("sign", template);
expect(response.status).toBe(200);
const event = await response.json();
expect(verifyEvent(event)).toBe(true);
expect(event.kind).toBe(7);
expect(event.tags).toEqual(template.tags);
expect((await h.post("publish", event)).status).toBe(200);
expect(h.calls).toHaveLength(1);
expect(h.calls[0].body).toEqual(JSON.parse(JSON.stringify(event)));
for (const length of [62, 63, 64]) {
const content = `:${"a".repeat(length)}:`;
const signed = await h.post("sign", { ...template, content });
expect(signed.status).toBe(200);
const boundaryEvent = await signed.json();
expect(boundaryEvent.content).toBe(content);
expect((await h.post("publish", boundaryEvent)).status).toBe(200);
}
expect(h.calls).toHaveLength(4);
for (const route of ["sign", "publish"]) {
for (const tags of [
[],
[["e", "bad"]],
[["e", "a".repeat(64), "", "reply"]],
[
["e", "a".repeat(64)],
["e", "b".repeat(64)],
],
]) {
expect(
(await h.post(route, { ...event, tags: [["h", "c"], ...tags] }))
.status,
).toBe(400);
}
expect(
(await h.post(route, { ...event, content: "x".repeat(65) })).status,
).toBe(400);
expect(
(await h.post(route, { ...event, content: `:${"a".repeat(65)}:` }))
.status,
).toBe(400);
expect(
(await h.post(route, { ...event, content: ` ${"x".repeat(64)}` }))
.status,
).toBe(400);
}
expect(h.calls).toHaveLength(4);
} finally {
await h.close();
}
});

test("both real sign and publish routes admit direct replies but reject arbitrary references before upstream I/O", async () => {
const h = await harness((call) =>
Response.json({ accepted: true, event_id: call.body.id }),
Expand Down
24 changes: 19 additions & 5 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "./sidebar-preferences.mjs";
import { createHostAdmission } from "../src/features/relay/host-admission.ts";
import { relayKlipySearchPath } from "../src/features/relay/gifs.ts";
import { validReactionContent } from "../src/features/relay/emoji.ts";
// Dev-only relay broker. Holds the local Buzz identity in this Node process and signs NIP-98 reads
// for the browser, so no key ever reaches page JavaScript. The dev server loads it whenever
// BUZZ_DEV_VIEWER is configured; production builds and tests never load it.
Expand Down Expand Up @@ -192,7 +193,7 @@ async function relayAuthority(fetch, relay) {
export function validMessageTemplate(event) {
return (
event &&
event.kind === 9 &&
[7, 9].includes(event.kind) &&
typeof event.content === "string" &&
event.content.trim().length > 0 &&
Buffer.byteLength(event.content) <= 32000 &&
Expand All @@ -208,6 +209,14 @@ export function validMessageTemplate(event) {
).length === 1 &&
(() => {
const references = event.tags.filter((tag) => tag[0] === "e");
if (event.kind === 7)
return (
event.content === event.content.trim() &&
validReactionContent(event.content) &&
references.length === 1 &&
references[0].length === 2 &&
/^[0-9a-f]{64}$/.test(references[0][1])
);
if (!references.length) return true;
const [reply] = references;
// This write surface supports direct-to-root replies, not arbitrary references.
Expand Down Expand Up @@ -308,7 +317,10 @@ export function relayBrokerPlugin({
})
.then(async (response) => {
if (!response.ok) throw new Error("GIF discovery failed");
return relayKlipySearchPath(await response.json());
const path = relayKlipySearchPath(await response.json());
// A relay can enable GIFs while this broker is still running.
if (!path) gifSearchPaths.delete(relay);
return path;
})
.catch((error) => {
gifSearchPaths.delete(relay);
Expand Down Expand Up @@ -413,7 +425,9 @@ export function relayBrokerPlugin({
});
const info = await response.json();
const gifSearchPath = relayKlipySearchPath(info);
gifSearchPaths.set(relay, Promise.resolve(gifSearchPath));
if (gifSearchPath)
gifSearchPaths.set(relay, Promise.resolve(gifSearchPath));
else gifSearchPaths.delete(relay);
const policyResponse = await fetchUpstream(
`${relay}/api/join-policy`,
{ redirect: "error", signal: AbortSignal.timeout(10000) },
Expand Down Expand Up @@ -509,7 +523,7 @@ export function relayBrokerPlugin({
viewer,
...(await getAuthority(relay)),
relayUrl: relay,
writeKinds: [9],
writeKinds: [7, 9],
sidebarPreferences: true,
readState: true,
agentLibrary: true,
Expand Down Expand Up @@ -840,7 +854,7 @@ export function relayBrokerPlugin({
const started = performance.now();
const event = finalizeEvent(
{
kind: 9,
kind: filters.kind,
content: filters.content,
created_at: filters.created_at,
tags: filters.tags,
Expand Down
27 changes: 27 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,33 @@ Retry while leaving Unicode available and retaining drafts. Only one picker owns
Emoji Mart's global dictionary at a time; scoped custom IDs and disposal prevent
old community entries leaking into search or Frequent. Historical messages and
existing reactions keep their signed emoji URLs after catalog changes.
Emoji-only messages stay at the large 42px size regardless of count; normal text
returns the message to its usual size. Long runs wrap instead of shrinking.
Selecting and copying custom emoji preserves their `:shortcode:` in plain text,
along with surrounding text and line breaks. Pasting into a community with that
emoji available resolves the shortcode through its existing composer catalog.
In the composer, Shift+Left/Right selects each rendered custom emoji as one unit,
preserving its full shortcode for copying, replacement and deletion. Reversing
direction shrinks the selection by one emoji. Visible shortcode text and emoji
that cannot be rendered retain ordinary text selection.
Custom emoji autocomplete adds no trailing space. The native caret uses the
regular composer text size while the emoji preview remains large.

Message and thread reaction rows have a Lucide smile-plus button after existing
reactions. Messages without reactions do not show it. The Emoji plugin supplies
the emoji-only picker
through its optional conversation tool `reactionComponent`; the shared message
row owns publication. The picker opens outside the scrolling list, closes on
selection or Escape, and returns focus to the plus button. Failed or unconfirmed
reaction delivery offers Retry reaction through the same outbox. Read-only
connections and archived channels do not expose the action.

The composer shows its GIF tab as soon as relay support is confirmed. Unsupported
results are retried when the picker reopens; the broker caches confirmed support
without retaining negative discovery results. Pickers
without tabs use a search radius equal to the container radius minus the 10px
inset; tabbed pickers keep the smaller 8px search radius.

Emoji uploads and management remain in the existing community workflow.

See [the shared catalog/send contract](relay-queries.md#community-emoji). The local
Expand Down
7 changes: 5 additions & 2 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ running broker needs one coordinated restart to gain the new filter. Reopening
the picker reuses its ready catalog; it is not a manual refresh fallback for an
older broker.

`session.messages.send`, `reply`, and `edit` resolve referenced `:shortcodes:`
`session.messages.send`, `reply`, `edit`, and `react` resolve referenced `:shortcodes:`
into original-URL emoji tags **before** the outbox assigns identity or signs. Text
without shortcode candidates does not wait. A cold/unavailable catalog throws
synchronously, so a composer retains its draft; plugins can await `ensure()` or
Expand All @@ -93,7 +93,10 @@ Message and reaction rendering uses only each event's own emoji tags, never the
current palette. Tagged edits replace mappings; legacy tagless edits preserve the
original message's mappings. All thumbnails use the captured session's media
resolver; unsupported or unloadable images fall back to literal shortcodes.
Uploads/management and reaction authoring are outside this slice.
Reaction authoring uses the existing outbox: kind 7, the loaded message's channel
(`h`) and target (`e`), and event-local custom emoji tags. The development broker
admits bounded reactions with exactly one canonical target and preserves kind 7
through signing. Uploads and emoji management remain outside this slice.

## Thread views

Expand Down
17 changes: 10 additions & 7 deletions scripts/design-system/check-color.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";

const SRC = new URL("../../src/shared/design-system", import.meta.url).pathname;
const VIEWER = new URL("../../tests/fixtures/design-system", import.meta.url)
.pathname;
const TOKENS_FILE = new URL(
"../../src/shared/design-system/styles/tokens.css",
import.meta.url,
).pathname;
const SRC = fileURLToPath(
new URL("../../src/shared/design-system", import.meta.url),
);
const VIEWER = fileURLToPath(
new URL("../../tests/fixtures/design-system", import.meta.url),
);
const TOKENS_FILE = fileURLToPath(
new URL("../../src/shared/design-system/styles/tokens.css", import.meta.url),
);

/**
* Colour utilities that may not carry an opacity modifier.
Expand Down
12 changes: 8 additions & 4 deletions scripts/design-system/check-type.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";

const SRC = new URL("../../src/shared/design-system", import.meta.url).pathname;
const VIEWER = new URL("../../tests/fixtures/design-system", import.meta.url)
.pathname;
import { fileURLToPath } from "node:url";

const SRC = fileURLToPath(
new URL("../../src/shared/design-system", import.meta.url),
);
const VIEWER = fileURLToPath(
new URL("../../tests/fixtures/design-system", import.meta.url),
);

/** Size roles a component may use. Kept in sync with typography.css. */
const SIZE_ROLES = [
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/emoji/CustomEmoji.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export function CustomEmoji({
return src && failed !== src ? (
<img
className={styles.customEmoji}
data-copy-emoji={literal}
draggable={false}
src={src}
alt={literal}
title={literal}
Expand Down
36 changes: 35 additions & 1 deletion src/bundled/emoji/Emoji.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,48 @@
background: var(--surface);
box-shadow: var(--elevation-card);
}
.emojiPopover:not([data-has-tabs]) {
/* Search sits inside 8px of picker padding plus its 2px margin. */
--picker-search-radius: max(0px, calc(var(--picker-container-radius) - 10px));
--picker-search-top-space: 0px;
}
.sharedSearchIcon {
position: absolute;
top: 19px;
top: 15px;
left: 18px;
z-index: 4;
color: var(--picker-search-muted);
pointer-events: none;
}
.reactionPositioner {
z-index: 100;
}
.reactionPopover {
position: relative;
bottom: auto;
left: auto;
max-width: calc(100vw - 32px);
}
.reactionTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-control);
color: var(--text-muted);
cursor: pointer;
}
.reactionTrigger:hover {
color: var(--text);
}
.reactionTrigger:focus-visible {
outline: 2px solid var(--text);
outline-offset: 2px;
}
.emojiPopover[data-has-tabs] .sharedSearchIcon {
top: 67px;
}
Expand Down
Loading
Loading