mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user