fix(desktop): custom emoji reaction rendering + picker autofocus (#831)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-03 08:53:43 -07:00
committed by GitHub
co-authored by Brain
parent 33cfc85293
commit 7797ae77f6
4 changed files with 242 additions and 3 deletions
@@ -378,6 +378,7 @@ export function MessageActionBar({
</div>
) : null}
<Picker
autoFocus
data={data}
custom={customEmojiCategory}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
@@ -2,6 +2,7 @@ import * as React from "react";
import type { TimelineReaction } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -10,7 +11,9 @@ const MAX_VISIBLE_REACTORS = 10;
/**
* Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set,
* otherwise the unicode/text glyph. `className` sizes the image to match the
* surrounding text.
* surrounding text. The relay URL is rewritten through the localhost media
* proxy (like every other relay-hosted <img>) — WKWebView bypasses WARP, so a
* direct relay URL gets a Cloudflare Access 403 and renders as a broken image.
*/
function EmojiGlyph({
reaction,
@@ -23,7 +26,7 @@ function EmojiGlyph({
return (
<img
alt={reaction.emoji}
src={reaction.emojiUrl}
src={rewriteRelayUrl(reaction.emojiUrl)}
className={cn(
"inline-block object-contain align-text-bottom",
className,
+165 -1
View File
@@ -420,6 +420,9 @@ function createMockCustomEmojiSetEvents(): RelayEvent[] {
[
["d", CUSTOM_EMOJI_SET_D_TAG],
["emoji", "sprout", "https://example.com/e2e/sprout.png"],
// A relay-hosted emoji whose URL matches rewriteRelayUrl()'s pattern,
// used by the reaction guard to assert the proxy rewrite fires.
["emoji", REACTION_EMOJI_SHORTCODE, REACTION_EMOJI_URL],
],
// The current mock identity owns this set, so the settings card's
// "My emoji" section is non-empty and removable.
@@ -516,6 +519,32 @@ declare global {
const DEFAULT_RELAY_HTTP_URL = "http://localhost:3000";
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
// NIP event kinds the mock reaction handlers emit.
const KIND_REACTION = 7; // NIP-25 reaction
const KIND_DELETION = 5; // NIP-09 deletion
// Fake media-proxy port the mock answers for `get_media_proxy_port`, so
// `rewriteRelayUrl()` produces a real `http://localhost:<port>/media/...` src
// in e2e (instead of the `sprout-media://` fallback). The reaction guard
// asserts against this exact port.
const MOCK_MEDIA_PROXY_PORT = 54321;
// A relay-hosted custom emoji used by the reaction guard. Its URL matches
// `rewriteRelayUrl()`'s `/media/{64-hex}.{ext}` pattern on the relay origin, so
// reacting with it exercises the proxy rewrite (unlike the `:sprout:` fixture,
// whose external example.com URL passes through unchanged).
const REACTION_EMOJI_SHORTCODE = "react";
const REACTION_EMOJI_SHA = "c".repeat(64);
const REACTION_EMOJI_URL = `${DEFAULT_RELAY_HTTP_URL}/media/${REACTION_EMOJI_SHA}.png`;
// A reaction-target message seeded into `general` with a real 64-hex event id.
// The reaction guard reacts to THIS message: getReactionTargetId() only accepts
// a 64-hex `e` tag, and the other mock seeds (and user-sent messages) use short
// non-hex ids, so they can't be reaction targets. Content is distinctive so the
// test locates its row without relying on seed ordering.
const REACTION_TARGET_EVENT_ID = "d".repeat(64);
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "sprout:e2e-identity-override.v1";
const DEFAULT_MOCK_IDENTITY = {
pubkey: "deadbeef".repeat(8),
@@ -1782,6 +1811,20 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
content: "Hey team — checking in.",
sig: "mocksig".repeat(20).slice(0, 128),
},
// Reaction-target seed for the custom-emoji reaction guard. Real
// 64-hex id so getReactionTargetId() accepts it as a reaction target
// (the short-id seeds above can't be reacted to). Backdated after the
// other seeds, so it stays at row index >= 2 and never displaces
// first()=welcome / nth(1)=alice that other specs rely on.
{
id: REACTION_TARGET_EVENT_ID,
pubkey: ALICE_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - 45,
kind: 9,
tags: [["h", channelId]],
content: REACTION_TARGET_CONTENT,
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: channelId === "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"
? [
@@ -2190,15 +2233,28 @@ function handleGetLikedNotes(): RawUserNotesResponse {
return { notes: [], next_cursor: null };
}
// A random 64-hex event id, matching the shape of real Nostr event ids
// (sha256 → 64 hex). Most mock events use the 32-hex `createMockEvent` default,
// but kind:7 reactions need a real 64-hex id: the timeline's deletion path only
// accepts 64-hex `e` tags (getDeletionTargets in formatTimelineMessages.ts), so
// a kind:5 targeting a 32-hex reaction id would be silently ignored and the
// reaction pill would never clear on toggle-off.
function mockEventId(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
function createMockEvent(
kind: number,
content: string,
tags: string[][],
pubkey = DEFAULT_MOCK_IDENTITY.pubkey,
createdAt = Math.floor(Date.now() / 1000),
id = crypto.randomUUID().replace(/-/g, ""),
): RelayEvent {
return {
id: crypto.randomUUID().replace(/-/g, ""),
id,
pubkey,
created_at: createdAt,
kind,
@@ -4557,6 +4613,102 @@ async function handleSendChannelMessage(
};
}
/** Locate the channel a stored mock event lives in (reactions carry no channel arg). */
function findMockEventChannel(eventId: string): string | undefined {
for (const [channelId, events] of mockMessages) {
if (events.some((event) => event.id === eventId)) {
return channelId;
}
}
return undefined;
}
/**
* Mock the `add_reaction` Tauri command. Mirrors the real Rust command: a
* kind:7 whose content is the emoji, plus — for a custom emoji — the NIP-30
* `["emoji", shortcode, url]` tag (shortcode normalized to match the relay).
* Recorded into the target's channel store and emitted live so the timeline's
* reaction aggregation renders the pill (the channel subscription includes
* kind:7). Unicode reactions carry no emoji tag, like the real command.
*/
async function handleAddReaction(
args: { eventId: string; emoji: string; emojiUrl?: string | null },
config: E2eConfig | undefined,
): Promise<void> {
const channelId = findMockEventChannel(args.eventId);
if (!channelId) {
throw new Error(`mock add_reaction: unknown target event ${args.eventId}`);
}
const emoji = args.emoji.trim();
// `h` routes the live event to the channel store (getChannelIdFromTags);
// `e` names the reaction target. For a custom emoji, the NIP-30
// `["emoji", shortcode, url]` tag carries the image URL.
const tags: string[][] = [
["h", channelId],
["e", args.eventId],
];
if (args.emojiUrl) {
const shortcode = emoji.replace(/^:+/, "").replace(/:+$/, "").toLowerCase();
tags.push(["emoji", shortcode, args.emojiUrl]);
}
const event = createMockEvent(
KIND_REACTION,
emoji,
tags,
getMockMemberPubkey(config),
Math.floor(Date.now() / 1000),
// 64-hex id so the kind:5 deletion emitted by remove_reaction is accepted
// by the timeline (getDeletionTargets requires a 64-hex `e` tag).
mockEventId(),
);
recordMockMessage(channelId, event);
emitMockLiveEvent(channelId, event);
}
/**
* Mock the `remove_reaction` Tauri command. Finds the active member's own
* kind:7 for this target+emoji, removes it from the store, and emits a kind:5
* deletion so the timeline drops the reaction (the real command deletes via a
* kind:5 too).
*/
async function handleRemoveReaction(
args: { eventId: string; emoji: string },
config: E2eConfig | undefined,
): Promise<void> {
const channelId = findMockEventChannel(args.eventId);
if (!channelId) {
return;
}
const myPubkey = getMockMemberPubkey(config).toLowerCase();
const emoji = args.emoji.trim();
const store = getMockMessageStore(channelId);
const reaction = store.find(
(event) =>
event.kind === KIND_REACTION &&
event.pubkey.toLowerCase() === myPubkey &&
event.content.trim() === emoji &&
event.tags.some((t) => t[0] === "e" && t[1] === args.eventId),
);
if (!reaction) {
return;
}
const index = store.indexOf(reaction);
store.splice(index, 1);
const deletion = createMockEvent(
KIND_DELETION,
"",
[["e", reaction.id]],
getMockMemberPubkey(config),
);
recordMockMessage(channelId, deletion);
emitMockLiveEvent(channelId, deletion);
}
async function handleGetEvent(
args: {
eventId: string;
@@ -5323,6 +5475,18 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleSendChannelMessage>[0],
activeConfig,
);
case "add_reaction":
return handleAddReaction(
payload as Parameters<typeof handleAddReaction>[0],
activeConfig,
);
case "remove_reaction":
return handleRemoveReaction(
payload as Parameters<typeof handleRemoveReaction>[0],
activeConfig,
);
case "get_media_proxy_port":
return MOCK_MEDIA_PROXY_PORT;
case "pick_and_upload_media":
return resolveMockUploadDescriptors(activeConfig);
case "upload_media_bytes":
+71
View File
@@ -93,3 +93,74 @@ test("custom emoji round-trips through select-all + send to the timeline", async
// The composer clears after send.
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(0);
});
// Regression guard for custom-emoji REACTIONS.
//
// The bug (shipped in the custom-emoji launch, PR #816): the reaction renderer
// put the relay emoji URL straight into <img src> without going through
// rewriteRelayUrl(). WKWebView bypasses WARP, so the direct relay URL gets a
// Cloudflare Access 403 and shows a broken image — even though the same emoji
// rendered fine inline in chat (that path rewrites). The chat path was covered
// by the tests above; the reaction path was not, which is why it slipped.
//
// This drives the real interactive react flow (hover -> Open reactions ->
// emoji-mart custom category) so it exercises the add_reaction Tauri command,
// then asserts the rendered reaction <img> src points at the localhost media
// proxy. On the pre-fix code the src would be the raw relay URL, so this test
// fails there — exactly the assertion that would have caught the bug.
//
// `:react:` is a relay-hosted fixture emoji (URL on the relay origin matching
// rewriteRelayUrl()'s /media/{64-hex}.{ext} pattern), and the mock bridge
// answers get_media_proxy_port with port 54321 so the rewrite resolves to a
// real localhost URL rather than the sprout-media:// fallback.
const REACTION_SHORTCODE = "react";
const MOCK_MEDIA_PROXY_PORT = 54321;
// A seeded message in `general` with a real 64-hex id — the only reactable
// target in mock mode (getReactionTargetId() requires a 64-hex `e` tag, which
// user-sent mock messages don't have). Mirrors REACTION_TARGET_CONTENT in the
// bridge.
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
test("reacting with a custom emoji renders via the localhost media proxy", async ({
page,
}) => {
await openGeneral(page);
// Reveal the hover action bar on the seeded reaction-target message, then
// open the reaction picker.
const row = page
.getByTestId("message-row")
.filter({ hasText: REACTION_TARGET_CONTENT })
.last();
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
// emoji-mart renders inside a Shadow DOM web component. Search by shortcode
// to surface the custom emoji, then click it.
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
// Custom emoji buttons carry the shortcode as their `title` (no aria-label).
await picker.locator(`button[title='${REACTION_SHORTCODE}']`).first().click();
// The reaction pill renders the custom emoji as an <img alt=":react:">. Its
// src must be the localhost proxy URL — proving rewriteRelayUrl() ran. A raw
// relay URL here is the bug.
const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
await expect(reactionImg).toHaveAttribute(
"src",
new RegExp(
`^http://localhost:${MOCK_MEDIA_PROXY_PORT}/media/[\\da-f]{64}\\.png$`,
),
);
// Toggle the reaction back off: click the pill, which fires remove_reaction
// -> emits a kind:5 deletion targeting the reaction event. The pill must
// disappear. Guards the mock-bridge deletion path: the reaction event needs a
// 64-hex id, because the timeline only honors deletions whose `e` tag is
// 64-hex (getDeletionTargets). A 32-hex reaction id leaves a stale pill here.
await row.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`).click();
await expect(reactionImg).toHaveCount(0);
});