aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 12:27:46 -0700
committerAdam Soltys <[email protected]>2026-10-09 12:27:46 -0700
commit776a846ba431cce9da5ef18585d766af5c154565 (patch)
treed84580f6dce660c253516bce3dc8ec11d0b73aa0
parent7e064d5c8b33f788bda2755a32e71e2fb01a00ba (diff)
downloadcoinosv3-776a846ba431cce9da5ef18585d766af5c154565.tar.gz
coinosv3-776a846ba431cce9da5ef18585d766af5c154565.zip
Reactions: a custom-emoji reaction draws as its picture in the feed. emojiImg falls back to :code: text while a row's media isn't ready, and the feed freezes that choice per row (so a post's body never reflows) — a reaction usually lands after its row was frozen, so :kek_lord: stayed text for good. Reaction emoji sit in the action bar at a fixed height, so they skip the freeze (reactImg), on the like button, the chips and the who-reacted panel. post-edit-test checks it (fails on the old code).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--src/features/messages.js9
-rw-r--r--tools/post-edit-test.js14
2 files changed, 19 insertions, 4 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 133b0b3..ae976a6 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -301,12 +301,17 @@ export function messagesFeature(ctx) {
}
const emojiImg = (code, url, cls = 'cemoji') =>
feedPaint && !feedMedia(url) ? ':' + code + ':' : h('img', { class: cls, src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
+ // A reaction's emoji always draws as its picture. The feed's freeze
+ // (emojiImg above) keeps a post's BODY from reflowing as pictures load, but
+ // a reaction sits in the action bar at a fixed size — and usually lands
+ // after its row was frozen, which left :kek_lord: as text for good.
+ const reactImg = (code, url) => h('img', { class: 'cemoji', src: url, alt: ':' + code + ':', title: ':' + code + ':', loading: 'lazy' });
// A reaction's content as shown on its chip: the picture for a :code: we
// know (its own tag taught it to us), the text otherwise.
function reactNode(emoji) {
const code = shortcodeOf(emoji);
const url = code && emojiUrl(code);
- return url ? emojiImg(code, url) : emoji;
+ return url ? reactImg(code, url) : emoji;
}
// the NIP-30 tag a :code: reaction travels with
const reactEmojiTags = (emoji) => {
@@ -3891,7 +3896,7 @@ export function messagesFeature(ctx) {
const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> })
// Resolve from this reaction's own tag: packs can reuse the same shortcode
// for different pictures, and an unrelated event must not change its face.
- const postReactNode = ({ emoji, url }) => url ? emojiImg(shortcodeOf(emoji), url) : emoji;
+ const postReactNode = ({ emoji, url }) => url ? reactImg(shortcodeOf(emoji), url) : emoji;
const boosts = new Map(); // note id -> Set<pubkey>
const noteCountsReady = new Set(); // a relay batch is complete for this note
const seenNoteEv = new Set(); // event ids already counted
diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js
index 405e4bc..0b8bf6f 100644
--- a/tools/post-edit-test.js
+++ b/tools/post-edit-test.js
@@ -29,7 +29,10 @@ const fixtures = [
];
const html = await buildHtml({ minify: true, pwa: false });
-const server = Bun.serve({ port: 5301, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+const server = Bun.serve({ port: 5301, fetch: (req) => new URL(req.url).pathname === '/kek.png'
+ ? new Response(PNG, { headers: { 'content-type': 'image/png' } })
+ : new Response(html, { headers: { 'content-type': 'text/html' } }) });
const browser = await puppeteer.launch({ relays: 'read', executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument((fx) => {
@@ -45,7 +48,7 @@ await page.evaluateOnNewDocument((fx) => {
const m = JSON.parse(data);
if (m[0] === 'REQ') {
const fs = m.slice(2);
- const evs = fx.filter((ev) => fs.some((f) => fits(ev, f)));
+ const evs = fx.concat(window.__extra || []).filter((ev) => fs.some((f) => fits(ev, f)));
setTimeout(() => {
for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) }));
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
@@ -106,6 +109,9 @@ try {
localStorage.setItem(k + ':profiles', JSON.stringify(Object.fromEntries(pks.map((pk, i) => [pk, { name: 'Person ' + i, t: Date.now() }]))));
localStorage.setItem('btc-wallet-feed:' + k, 'following');
}, [base, [AUTHOR.pk, ME], [MINE, EDITED, GONE, FORGED]]);
+ // my reaction with a custom emoji, arriving after the row is drawn
+ const KEK = finalizeEvent({ kind: 7, created_at: now - 50, content: ':kek_lord:', tags: [['e', FORGED.id], ['p', AUTHOR.pk], ['emoji', 'kek_lord', 'http://localhost:5301/kek.png']] }, ME_SK);
+ await page.evaluateOnNewDocument((e) => { window.__extra = e; }, [KEK]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await openFeed();
@@ -116,6 +122,10 @@ try {
check('...marked as edited', await page.evaluate(() => [...document.querySelectorAll('[data-zap-post]')].some((n) => n.textContent.includes('without the typo') && /edited/.test(n.textContent))));
check('a deletion by the author hides the post', await waitGone('a post its author took back'));
check('a stranger\'s deletion and edit are ignored', await has('a post a stranger tried to delete') && !(await has('a stranger rewrote this')));
+ check('my custom-emoji reaction shows as its picture, not :kek_lord:', await page.evaluate(() => {
+ const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => n.textContent.includes('a stranger tried to delete'));
+ return !!r && !!r.querySelector('.note-acts img.cemoji[alt=":kek_lord:"]') && !r.querySelector('.note-acts').textContent.includes(':kek_lord:');
+ }));
check('no Edit or Delete on someone else\'s post', await more('first draft without') && !(await sheetHas('Edit')) && !(await sheetHas('Delete')));
await sheetClick('Back'); await sleep(300);