aboutsummaryrefslogtreecommitdiffstats
path: root/src/redis.coffee
diff options
context:
space:
mode:
Diffstat (limited to 'src/redis.coffee')
0 files changed, 0 insertions, 0 deletions
.io> 2026-10-19 12:21:40 -0700 committer Adam Soltys <[email protected]> 2026-10-19 12:21:47 -0700 Add timestamp comments, reactions and zaps with audio waveform seeking' href='/coinosv3.git/commit/tools/media-moments-feed-test.js?id=eca472c893fd128fa863b5d92835b6d9e71b445f'>eca472c ^
1e0d551 ^





eca472c ^
1e0d551 ^
eca472c ^



1e0d551 ^


eca472c ^



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181








































































































































                                                                                                                                                                                                                                                                



                                                                                                                                                                                                     





                                                                                                                                       
                                                                                                                                                                                                                                                      
                                                                     
                                    











                                                                                                                                                                                        





                                                                                                                                  
                   
                                                                                             



                                                                                                                                                                                                                                  


                                                                                                                                                                                                          



                                                                      
// Full feed integration with local media and relay acknowledgements; no events leave the test.
// Run: bun tools/media-moments-feed-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { buildHtml } from '../build.js';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/media-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
// index last (no faststart), from a server that ignores ranges
ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);

ff(['-f', 'lavfi', '-i', 'sine=frequency=440:duration=12', `${dir}/audio.mp3`]);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: (req) => {
  const path = new URL(req.url).pathname;
  if (path === '/audio.mp3') {
    const f = Bun.file(dir + '/audio.mp3'), m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
    if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'audio/mpeg', 'content-range': `bytes ${a}-${b}/${f.size}`, 'accept-ranges': 'bytes' } }); }
    return new Response(f, { headers: { 'content-type': 'audio/mpeg' } });
  }
  if (path.startsWith('/v/')) {
    const f = Bun.file(dir + path.slice(2));
    const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
    if (m && path !== '/v/slow.mp4') {
      const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1);
      // c.mp4 stands in for a 50 MB file: its server says so
      const total = path === '/v/c.mp4' && m[2] === '65535' ? 50000000 : f.size; // (to the probe; a player gets the real file)
      return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/mp4', 'content-range': `bytes ${a}-${b}/${total}`, 'accept-ranges': 'bytes' } });
    }
    return new Response(f, { headers: { 'content-type': 'video/mp4' } });
  }
  if (path.startsWith('/i/')) return new Response(Bun.file(dir + path.slice(2)), { headers: { 'content-type': 'image/jpeg' } });
  if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
  return new Response(html, { headers: { 'content-type': 'text/html' } });
} });
const base = server.url.origin;
const SK = generateSecretKey(), PK = getPublicKey(SK);
const FAN = generateSecretKey();
const now = Math.floor(Date.now() / 1000);
const post = (i, content, tags = []) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 60, tags, content }, SK);
const notes = [
  post(0, 'first clip, look at this ' + base + '/v/a.mp4'),
  post(1, 'a plain post with no media at all'),
  post(2, 'sunset over the bay ' + base + '/i/p1.jpg'),
  post(3, base + '/v/b.mp4'),
  post(4, 'two at once ' + base + '/i/p2.jpg ' + base + '/i/p3.jpg'),
  // the post says this one is 50 MB (it isn't; the post is believed)
  post(5, 'a huge clip ' + base + '/v/c.mp4', [['imeta', 'url ' + base + '/v/c.mp4', 'm video/mp4', 'size 50000000', 'dim 360x640']]),
  post(6, 'index last ' + base + '/v/slow.mp4'),
];
notes.unshift(post(9, 'Listen ' + base + '/audio.mp3', [['imeta', 'url ' + base + '/audio.mp3', 'waveform 1 5 2 8 4 10']]));
const LIKE = finalizeEvent({ kind: 7, created_at: now - 50, tags: [['e', notes[2].id], ['p', PK]], content: '+' }, FAN);

const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
const page = await browser.newPage();
await page.evaluateOnNewDocument((like) => {
  const Real = window.WebSocket;
  window.WebSocket = function (...a) {
    const ws = new Real(...a);
    const send = ws.send.bind(ws);
    ws.send = (data) => {
      try {
        const m = JSON.parse(data);
        if (m[0] === 'REQ') {
          const f = m[2] || {};
          const evs = (f.kinds || []).includes(7) && (f['#e'] || []).includes(like.tags[0][1]) ? [like] : [];
          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]]) }));
          }, 0);
          return;
        }
        if (m[0] === 'EVENT') return send(data);
      } catch {}
      return send(data);
    };
    return ws;
  };
  window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
}, LIKE);
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 200; i++) { if (await page.evaluate(fn)) return true; await sleep(200); } return false; };
const mode = (m) => page.evaluate((x) => document.querySelector(`.media-mode[data-mode="${x}"]`).click(), m);
const shot = async (name) => { if (process.env.SHOTS) await page.screenshot({ path: `${process.env.SHOTS}/${name}.png` }); };
// which slide fills the screen, and what's playing
const pagerState = () => page.evaluate(() => {
  const p = document.querySelector('.media-pager');
  if (!p) return null;
  const sc = p.querySelector('.mp-scroll');
  const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
  const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
  const vids = slides.map((s) => s.querySelector('video'));
  return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
    playing: vids.map((v) => !!(v && !v.paused)), show: p.classList.contains('show'),
    meta: getComputedStyle(slides[i].querySelector('.mp-meta')).opacity };
});

try {
  await page.setViewport({ width: 390, height: 844 });
  await page.goto(base, { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
  await click('create a new wallet'); await sleep(600);
  await page.waitForSelector('.words .w .t');
  const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await click('skip verification');
  await waitText('receive', 20000);
  const key = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Shutterbug', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [key, PK, notes]);
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
  await page.waitForSelector('.notes-feed:not([data-booting])', { timeout: 20000 });


  await page.waitForSelector('.note-audio audio');
  await page.evaluate(() => document.querySelector('.note-audio audio').play());
  await page.waitForSelector('.note-audio .media-timeline');
  await page.waitForFunction(() => document.querySelectorAll('.note-audio .media-wave i').length === 6);
  check('published audio waveform renders in the actual feed', true);
  await page.evaluate(() => { const a = document.querySelector('.note-audio audio'); a.pause(); a.currentTime = 5; });
  await sleep(100);
  // the post's own Like carries where the clip is: no buttons of its own for that
  const act = (sel, label) => page.evaluate(([sel, label]) => document.querySelector(sel).closest('[data-zap-post]').querySelector('.note-acts [aria-label="' + label + '"]').click(), [sel, label]);
  check('no "at this time" buttons', await page.evaluate(() => !document.body.innerText.includes('at this time')));
  await act('.note-audio', 'Like');
  await page.waitForSelector('.msg-sheet');
  await page.evaluate(() => document.querySelector('.msg-sheet button').click());
  await sleep(400);
  const reaction = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 7 && e.tags.some(t => t[0] === 'media')));
  check('reaction is signed with the selected file and position', reaction?.tags.some(t => t[0] === 'media' && t[2] === '5'));
  check('reaction marker appears while paused', await page.evaluate(() => !!document.querySelector('.note-audio .media-mark')));
  check('...on the bar at its moment', await page.evaluate(() => { const m = document.querySelector('.note-audio .media-mark'); return Math.abs(parseFloat(m.style.left) - 5 / document.querySelector('.note-audio audio').duration * 100) < 0.5; }));
  await page.screenshot({ path: '/tmp/media-timeline-playing.png' });
  await act('.note-audio', 'Reply');
  await page.waitForSelector('.thread-reply-input');
  check('timestamp is visible in the reply composer', (await page.evaluate(() => document.body.innerText)).includes('Comment at 0:05'));
  await page.evaluate(() => { const input = document.querySelector('.thread-reply-input'); input.value = 'Great moment'; input.dispatchEvent(new Event('input', { bubbles: true })); });
  await page.click('.thread-reply-send');
  await sleep(500);
  const comment = await page.evaluate(() => window.__relayGuard.events.find(e => e.kind === 1 && e.content.includes('Great moment')));
  check('comment includes a readable timestamp and signed media context', comment?.content === '[0:05] Great moment' && comment.tags.some(t => t[0] === 'media' && t[2] === '5'));
  check('comment shows a jump-to-time link', await page.evaluate(() => !!document.querySelector('.media-time-link')));
  await page.evaluate(() => document.querySelector('.media-time-link').click());
  await page.waitForFunction(() => document.querySelector('.note-audio audio')?.currentTime >= 5);
  check('the comment link starts playback at its timestamp', true);
  await click('back'); await sleep(250);
  // a clip in the pager: its ♥ carries the moment, which then sits on its seek bar
  await page.evaluate(() => { const v = document.querySelector('video.note-video'); const r = v.getBoundingClientRect();
    v.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 3 })); });
  await page.waitForSelector('.media-pager .mp-cur video', { timeout: 8000 });
  await page.waitForFunction(() => document.querySelector('.media-pager .mp-cur video').duration > 0, { timeout: 8000 });
  await page.evaluate(() => { const v = document.querySelector('.media-pager .mp-cur video'); v.pause(); v.currentTime = 1; });
  await sleep(150);
  await page.evaluate(() => document.querySelector('.media-pager .mp-cur .mp-like').click());
  await page.waitForSelector('.msg-sheet');
  await page.evaluate(() => document.querySelector('.msg-sheet button').click());
  await sleep(300);
  check('video reaction carries the video URL and selected second', await page.evaluate(() => window.__relayGuard.events.some(e => e.kind === 7 && e.tags.some(t => t[0] === 'media' && t[1].endsWith('.mp4') && t[2] === '1'))));
  check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));
  check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline')));
  await page.screenshot({ path: '/tmp/media-timeline-pager.png' });
  check('no browser exceptions', errs.length === 0, errs.join(' | '));
  await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
} finally { await browser.close(); server.stop(); }
process.exit(ok ? 0 : 1);