diff options
| -rw-r--r-- | src/app.js | 6 | ||||
| -rw-r--r-- | src/features/messages.js | 89 | ||||
| -rw-r--r-- | src/marmot-client.js | 2 | ||||
| -rw-r--r-- | src/media-upload.js | 10 | ||||
| -rw-r--r-- | src/style.css | 17 | ||||
| -rw-r--r-- | tools/profile-publish-race-test.js | 18 | ||||
| -rw-r--r-- | tools/thread-reply-test.js | 21 |
7 files changed, 137 insertions, 26 deletions
@@ -3939,7 +3939,11 @@ async function publicMediaUpload(file) { if (error instanceof MediaUploadError && error.code === 'unauthorized') throw new Error(t('mediaUploadUnauthorized')); if (error instanceof MediaUploadError && error.code === 'too-big') throw new Error(t('mediaUploadTooBig')); if (error instanceof MediaUploadError && error.code === 'unsupported') throw new Error(t('mediaUploadUnsupported')); - throw new Error(t('msgUploadFailed')); + // what went wrong, said: "check your connection" alone hid a signer + // that never answered and hosts that refused the file + const why = String((error instanceof MediaUploadError ? error.details : '') || error?.message || '').slice(0, 200); + console.warn('media upload failed', error, why); + throw new Error(why && why !== 'failed' ? `${t('msgUploadFailed')} (${why})` : t('msgUploadFailed')); } finally { ui.mediaProgress = null; } diff --git a/src/features/messages.js b/src/features/messages.js index fd4a8a3..6c6b48d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js'; // coinos's own relay and primal: both take DM gift wraps and pass call // messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it // refused or ignored the household IP's publishes, call answers among them. -const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net']; +// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads; +// relay.coinos.io stays on while older inbox lists still name it +const MESSAGES_RELAY = 'wss://messages.coinos.io'; +const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net']; +// what this app put in a wallet key's kind 10050 before messages.coinos.io +const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io']; const NOTIFIER = 'https://nwcpush.coinos.io'; const APP_BASE = 'https://v3.coinos.io'; const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state @@ -3314,12 +3319,19 @@ export function messagesFeature(ctx) { // — the user's other clients own that. And when a login identity IS active, // don't advertise the seed key at all: it's internal, not a second inbox // (advertising it is what let DMs land on a phantom identity). + // A list this app wrote with its old defaults (nothing else in it) is + // brought up to today's, so senders find messages.coinos.io first. async function ensureDmRelayList() { if (!wallet.nostr || !wallet.nostr.sk) return; if (hook('nostrLoginIdentity')) return; const pk = wallet.nostr.pk; const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500); - if (existing.length) return; + if (existing.length) { + const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a)); + const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, '')); + const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u)); + if (!ours || urls.includes(DM_RELAYS[0])) return; + } const evt = finalizeEvent({ kind: 10050, content: '', @@ -4123,7 +4135,8 @@ export function messagesFeature(ctx) { // ---- profile notes: latest public posts & replies ----------------------- - const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])]; + // (not messages.coinos.io: it takes messages, not posts) + const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])]; // ---- zap tallies --------------------------------------------------------- // How much a post or chat message has been zapped: the public receipts @@ -4528,10 +4541,12 @@ export function messagesFeature(ctx) { const momentsOn = (id, file) => [...(timedActivity.get(id)?.values() || [])] .filter(x => x.moment.url === mediaMoment(file, 0)?.url && !hiddenPk(x.ev.pubkey) && !isDeleted(x.ev)) .sort((a, b) => a.moment.seconds - b.moment.seconds) - .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(x.text || x.icon).slice(0, 240) })); + .map(x => ({ ...x, text: displayName(x.ev.kind === 9735 || x.ev.kind === 9737 ? zapperOf(x.ev) || x.ev.pubkey : x.ev.pubkey) + ' · ' + String(momentStripped(x.ev, x.text) || x.icon).slice(0, 240) })); // how a moment shows on a bar: a reaction as its emoji (a :code: as its - // picture, from the reaction's own tag), a zap ⚡, a reply ↩ + // picture, from the reaction's own tag), a zap ⚡, a reply as its + // author's face function momentGlyph(x) { + if (x.ev.kind === 1 || x.ev.kind === 1111) return avatar(x.ev.pubkey, 'chat-avatar media-mark-face', false); if (x.ev.kind !== 7) return x.icon; const code = shortcodeOf(x.ev.content); const tag = code && x.ev.tags.find(t => t[0] === 'emoji' && t[1] === code && t[2]); @@ -4551,6 +4566,19 @@ export function messagesFeature(ctx) { } return null; } + // A comment made at a moment of a clip opens with its time as a link that + // plays the clip from there; the "[0:03] " other clients read as text is + // left out of the words beside it. + function momentLink(ev) { + const m = readMoment(ev); + if (!m) return null; + return h('a', { class: 'media-time-link', href: m.url + '#t=' + m.seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { + e.stopPropagation(); + const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); + if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } + } }, '▶ ' + mediaClock(m.seconds)); + } + const momentStripped = (ev, text) => (readMoment(ev) ? String(text || '').replace(/^\s*\[\d{1,2}(?::\d{2}){1,2}\]\s*/, '') : text); const withMoment = (ev) => { const m = momentOf(ev); return m ? { ...ev, mediaMoment: m } : ev; }; function attachMediaTimeline(player, url, explicitEvent) { if (player._timeline) return; @@ -12065,13 +12093,9 @@ export function messagesFeature(ctx) { zapText(ev) ? h('div', {}, ...noteBody(zapText(ev))) : null) : foreign ? h('div', { class: 'note-text small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, (ev.kind === 64 ? '\u265f ' : '') + altText) - : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(shownTranslation(ev)?.text ?? noteText(ev), 0, noteEmojiTags(ev)))), - readMoment(ev) ? h('a', { class: 'small media-time-link', href: readMoment(ev).url + '#t=' + readMoment(ev).seconds, target: '_blank', rel: 'noopener noreferrer', onClick: e => { - e.stopPropagation(); - const m = readMoment(ev); - const player = [...document.querySelectorAll('audio, video')].find(v => mediaMoment(v.dataset.clip || v.getAttribute('src'), 0)?.url === m.url); - if (player) { e.preventDefault(); const seek = () => { player.currentTime = Math.min(m.seconds, Number.isFinite(player.duration) ? player.duration : m.seconds); }; if (player.readyState) seek(); else player.addEventListener('loadedmetadata', seek, { once: true }); player.play().catch(() => {}); player.scrollIntoView({ block: 'center' }); } - } }, '▶ ' + mediaClock(readMoment(ev).seconds)) : null, + : h('div', { class: 'note-text', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, + momentLink(ev), + ...(ev.kind === ARTICLE ? (open ? [articleCard(ev)] : articleBody(ev)) : noteBody(momentStripped(ev, shownTranslation(ev)?.text ?? noteText(ev)), 0, noteEmojiTags(ev)))), foreign || pending ? null : translateLine(ev), ev.kind === POLL ? pollBox(ev) : null, pending ? null : noteActions(pk, ev, { canZap }), @@ -12522,7 +12546,9 @@ export function messagesFeature(ctx) { onKeydown: (e) => { mentionKey(e); if (e.defaultPrevented) return; - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } + // a phone's Return is a new line (Reply is the button); a + // keyboard's Enter sends + if (e.key === 'Enter' && !e.shiftKey && !matchMedia('(pointer: coarse)').matches) { e.preventDefault(); e.target.closest('.col').querySelector('.thread-reply-send')?.click(); return; } if (e.ctrlKey && (e.key === 'j' || e.key === 'J')) { e.preventDefault(); const el = e.target; @@ -12563,7 +12589,19 @@ export function messagesFeature(ctx) { previewBtn(!!s.preview, () => { s.preview = !s.preview; render(); }), h('button', { class: 'btn-ghost btn-sm thread-reply-cancel', type: 'button', - onClick: () => { s.replying = false; s.preview = false; s.mediaMoment = null; render(); }, + // Cancel discards: the typed text, its attachments and the box. + // (It only folded the `replying` flag before, and the box stays + // up while a draft has text — so with anything typed it did + // nothing visible.) The field may still be focused, and the + // morph leaves a focused field's value alone: clear it by hand. + onClick: () => { + const text = s.draft || ''; + s.draft = ''; s.replying = false; s.preview = false; s.mediaMoment = null; + ui.postMedia = (ui.postMedia || []).filter((m) => !text.includes(m.url)); + const inp = document.querySelector('.thread-reply-input'); + if (inp) inp.value = ''; + render(); + }, }, t('cancel'))), h('button', { class: 'btn-primary btn-sm thread-reply-send', disabled: !!s.sending || !!ui.postUploading, @@ -12754,8 +12792,15 @@ export function messagesFeature(ctx) { // when empty; move them when they pointed at our (old) address. A // deliberately foreign lud16/nip05 is the user's business. if (addr) { - for (const k of ['lud16', 'nip05']) - if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; + const domain = addr.split('@')[1]; + for (const k of ['lud16', 'nip05']) { + const cur = merged[k]; + if (!cur || cur === oldAddr) { merged[k] = addr; continue; } + // a name at our own domain that is not ours and no longer answers + // (the placeholder a migration released) is ours now — Save is the + // heal an interactive signer can't do in the background + if (typeof cur === 'string' && cur !== addr && cur.split('@')[1] === domain && !/^_@/.test(cur) && await addressDead(cur)) merged[k] = addr; + } } if (merged.name) merged.display_name = merged.name; const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; @@ -12783,6 +12828,11 @@ export function messagesFeature(ctx) { // alone. Once per address per session. const addrHealed = new Set(); async function healStaleAddress(olds, myAddr) { + // Amber by intent needs a tap per signature: a background publish would + // sit in the kind-0 queue forever and block every later one. Save in the + // profile editor repoints the address for these users instead. + const id = await identity().catch(() => null); + if (!id || (id.signer && id.signer.interactive)) return; for (const old of olds) { if (addrHealed.has(old)) continue; addrHealed.add(old); @@ -12944,7 +12994,10 @@ export function messagesFeature(ctx) { if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; - const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + // the lightning line too: a stale same-domain lud16 (jar@ after the + // rename to jarryd@) showed under the right NIP-05 as a second address + const rawLud = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; + const lud16 = rawLud ? (stale(rawLud) ? myAddr : rawLud) : null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; // an about of "~" or a lone character is noise, not a bio. // Until the full kind 0 lands, the light profile cache answers — it was @@ -18391,6 +18444,8 @@ export function messagesFeature(ctx) { // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, + // the profile editor's Save, without the editor (tests) + async saveProfileDraft(e) { const id = await identity(); return withKind0Lock(() => saveProfileNow(id, { name: '', about: '', picture: '', banner: '', ...e })); }, // opens the profile, unless the caller has a step to take first (the // Accounts page switches to that identity before showing its profile) profileChip(pk, size, onOpen) { diff --git a/src/marmot-client.js b/src/marmot-client.js index b514216..707003f 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -16,7 +16,7 @@ import { dlog } from './debug.js'; // Where the groups we found live. Signed into group state, so every member // reads and writes the same list. Not relay.damus.io or relay.nostr.band: // White Noise (MDK's RETIRED_RELAY_HOSTS) never dials those. -export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net']; +export const GROUP_RELAYS = ['wss://messages.coinos.io', 'wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net']; // Where White Noise looks people up. It will not invite an account whose // relay lists (kinds 10002 and 10050) it cannot find, and one whose profile diff --git a/src/media-upload.js b/src/media-upload.js index f851943..7f020cf 100644 --- a/src/media-upload.js +++ b/src/media-upload.js @@ -73,6 +73,8 @@ function putWithProgress(url, { body, headers, signal }, onProgress) { }); } +const hostOf = (server) => { try { return new URL(server).hostname.replace(/^blossom\./, ''); } catch { return server; } }; + // BUD-02 upload. `signEvent` is deliberately injected: the app can use its // signed-in Nostr identity, while tests need no secret key. export async function uploadPublicMedia(file, signEvent, { @@ -97,7 +99,7 @@ export async function uploadPublicMedia(file, signEvent, { // upload timeout. Any HTTP answer, even an error, means it's there. if (i < servers.length - 1) { try { await fetcher(server.replace(/\/$/, '') + '/upload', { method: 'HEAD', signal: AbortSignal.timeout(5000) }); } - catch (error) { failures.push({ status: 0, reason: 'unreachable' }); continue; } + catch (error) { failures.push({ host: hostOf(server), status: 0, reason: 'unreachable' }); continue; } } try { const init = { @@ -116,12 +118,12 @@ export async function uploadPublicMedia(file, signEvent, { try { body = await response.json(); } catch {} const url = response.ok && responseUrl(body); if (url) return mediaUrl(url, server, file); - failures.push({ status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); + failures.push({ host: hostOf(server), status: response.status, reason: response.headers.get('x-reason') || body?.message || '' }); } catch (error) { - failures.push({ status: 0, reason: error?.message || '' }); + failures.push({ host: hostOf(server), status: 0, reason: error?.message || '' }); } } - const details = failures.map((x) => [x.status, x.reason].filter(Boolean).join(' ')).filter(Boolean).join('; '); + const details = failures.map((x) => x.host + ': ' + ([x.status, x.reason].filter(Boolean).join(' ') || '?')).join('; '); if (failures.length && failures.every((x) => x.status === 401 || x.status === 403)) throw new MediaUploadError('unauthorized', details); if (failures.length && failures.every((x) => x.status === 413)) throw new MediaUploadError('too-big', details); if (failures.length && failures.every((x) => x.status === 415)) throw new MediaUploadError('unsupported', details); diff --git a/src/style.css b/src/style.css index 460b584..bf61f6a 100644 --- a/src/style.css +++ b/src/style.css @@ -2018,7 +2018,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut /* A post's audio/video timeline: the waveform (audio), and what was said, reacted or zapped at a moment, sitting over the bar at that moment. */ -.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); } +.media-timeline { padding: 8px 0; white-space: normal; color: var(--text); } .note-video-box .media-timeline:not(.has-marks) { display: none; } .media-time { font-variant-numeric: tabular-nums; } .media-seek { position: relative; height: 42px; --played: 0%; } @@ -2030,12 +2030,20 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut .has-wave .media-wave::after { content: ''; position: absolute; inset: 0 0 0 var(--played); background: var(--bg, #fff); opacity: .55; pointer-events: none; } .media-seek input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: .01; cursor: pointer; } .media-seek:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } -.media-marks { position: absolute; left: 0; right: 0; top: -22px; height: 22px; pointer-events: none; } +/* the marks hang just under the line, at their moment (a plain bar: inside + its touch strip); under a waveform, below it */ +.media-marks { position: absolute; left: 0; right: 0; top: 24px; height: 18px; pointer-events: none; } +.has-wave .media-marks { top: 100%; height: 22px; } +.media-seek.has-wave:has(.media-mark) { margin-bottom: 22px; } +.media-mark { bottom: auto; top: 0; } .media-mark, .mp-mark { position: absolute; bottom: 0; transform: translateX(-50%); padding: 0 2px; margin: 0; border: 0; background: none; font-size: 16px; line-height: 1; cursor: pointer; pointer-events: auto; } .media-mark .cemoji, .mp-mark .cemoji { width: 20px; height: 20px; vertical-align: bottom; } +.media-mark-face { display: block; width: 20px !important; height: 20px !important; min-width: 0; font-size: 8px; border-radius: 50%; } +.mp-mark .media-mark-face { width: 26px !important; height: 26px !important; box-shadow: 0 0 0 1.5px #fff; } +.mp-pop .media-mark-face { width: 36px !important; height: 36px !important; box-shadow: 0 0 0 2px #fff; } /* the pager's: above its seek bar, there when the bar is */ .mp-marks { position: absolute; left: 0; right: 0; bottom: 10px; height: 26px; pointer-events: none; } .mp-mark { font-size: 20px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } @@ -2057,6 +2065,11 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut } @media (prefers-reduced-motion: reduce) { .mp-pop { animation: none; opacity: 0; } } +/* a timed comment's time: a chip that plays the clip from there */ +.media-time-link { display: inline-flex; align-items: center; margin-right: 6px; padding: 1px 8px; border-radius: 999px; + background: var(--accent-soft); color: var(--accent); font-size: .85em; font-weight: 650; font-variant-numeric: tabular-nums; + text-decoration: none; vertical-align: 1px; white-space: nowrap; } +.media-time-link:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); } .media-moment-detail { overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; margin-top: 5px; } .media-moment-detail:empty { display: none; } .live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; } diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js index 390c01b..becf375 100644 --- a/tools/profile-publish-race-test.js +++ b/tools/profile-publish-race-test.js @@ -67,9 +67,10 @@ const wallet = { loadFeatureState: () => null, saveFeatureState: () => {}, _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, }; +let namesAddr = null; // what the registrar says our address is const feature = messagesFeature({ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null, - hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), + hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : name === 'namesAddress' ? namesAddr : null), }); console.log('[the migration: rename and avatar import fired together]'); @@ -91,5 +92,20 @@ await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content); check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last)); +console.log('[Save in the editor repoints a dead same-domain address]'); +// The heal never ran (an Amber user: no background signatures), so the kind 0 +// still names the released placeholder. The domain says jar is dead, other +// is alive. +store.push(finalizeEvent({ kind: 0, created_at: now + 1000, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk)); +namesAddr = '[email protected]'; +globalThis.fetch = async (url) => /lnurlp\/jar$/.test(url) + ? { ok: false, status: 500, json: async () => null } + : { ok: true, status: 200, json: async () => ({ tag: 'payRequest', callback: 'https://x/cb' }) }; +published.length = 0; +await feature.saveProfileDraft({ uname: 'jarryd', name: 'Jarryd' }); +const saved = JSON.parse(published.filter((e) => e.kind === 0).pop().content); +check('the dead placeholder moves to the claimed address', saved.lud16 === '[email protected]', JSON.stringify(saved)); +check('a same-domain address that still answers is left alone', saved.nip05 === '[email protected]', saved.nip05); + console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns'); process.exit(fails ? 1 : 0); diff --git a/tools/thread-reply-test.js b/tools/thread-reply-test.js index 738a31c..f3bda91 100644 --- a/tools/thread-reply-test.js +++ b/tools/thread-reply-test.js @@ -78,6 +78,27 @@ try { await page.click('#app .note-act'); check('Reply immediately shows its composer beneath the tapped post', await inline()); check('the input receives focus before relay queries finish', await page.evaluate(() => document.activeElement.matches('.thread-reply-input'))); + // Cancel with text typed: the box folds and the draft is gone (it used to + // stay open — the box shows while a draft has text, and Cancel only + // cleared the replying flag) + await page.type('.thread-reply-input', 'Half a thought'); + await page.evaluate(() => document.querySelector('.thread-reply-cancel').click()); + check('Cancel with text typed hides the box and discards the draft', await page.evaluate(() => + !document.querySelector('.thread-reply-input') && !(test.ui.noteThread.draft || '').length)); + await page.evaluate(() => document.querySelector('#app .note-act').click()); // one atomic tap, as below + await page.waitForSelector('.thread-reply-input'); + check('Reply again opens an empty field', await page.evaluate(() => document.querySelector('.thread-reply-input')?.value === '')); + // a phone: Return is a new line, not a send + await page.evaluate(() => { const mm = window.matchMedia; window._mm = mm; window.matchMedia = (q) => q === '(pointer: coarse)' ? { matches: true } : mm(q); }); + await page.type('.thread-reply-input', 'Line one'); + await page.keyboard.press('Enter'); + await page.type('.thread-reply-input', 'two'); + check("on a phone, Return adds a line instead of sending", await page.evaluate(() => + document.querySelector('.thread-reply-input')?.value === 'Line one\ntwo' && sent.length === 0), await page.evaluate(() => document.querySelector('.thread-reply-input')?.value)); + await page.evaluate(() => { window.matchMedia = window._mm; }); + await page.focus('.thread-reply-input'); + await page.keyboard.down('Control'); await page.keyboard.press('a'); await page.keyboard.up('Control'); + await page.keyboard.press('Backspace'); await page.type('.thread-reply-input', 'A reply before the root loads'); // (one atomic tap: a repaint landing between finding the button and clicking it replaces it) await page.waitForFunction(() => !document.querySelector('.thread-reply-send')?.disabled); |
