diff options
| author | Adam Soltys <[email protected]> | 2026-10-15 08:35:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-15 08:35:59 -0700 |
| commit | 849e3d172354c87c14991392bd075334dc5f68af (patch) | |
| tree | b6ae24bdf7f8abd13f1b44f4915336d6cb60f9b6 /src | |
| parent | b540d3fab2579da40e0bcdb59581f83b0b742947 (diff) | |
| download | coinosv3-849e3d172354c87c14991392bd075334dc5f68af.tar.gz coinosv3-849e3d172354c87c14991392bd075334dc5f68af.zip | |
Thread pages: a picture opens in the media pager, over the thread's pictures; a tap brings up the caption and controls wherever the pager was opened from. A picture tapped on a thread page opened alone, full screen, and a tap on it closed it — no caption, no controls, nothing to swipe to (Adam, 2026-10-16: "clicking again dismisses it instead of showing me the caption and controls… let's make it consistent no matter where media is opened from"). Now a thread is a pager source like the feed and a profile page (pageRowOf/pagerSource `thread`, keyed by the thread's root): its slides are the root's pictures and then its replies', in the order the page draws them (threadTree/threadOrder, the tree walk factored out of threadScreen), with nothing more to fetch; closing after moving on brings that post on screen. And in any pager opened from a page, the first tap shows the post's details and the next closes it — the tap on the picture that opened it no longer closes it outright, so the feed and a profile behave the same as the thread. A face or a chat's picture still opens alone. tools/thread-pager-test.js (the thread's pictures only, root first then the reply's; counter 1/2; a tap shows the author and the words; right and down; Escape leaves the thread with the post on screen; a reply's picture; tap, then tap closes); feed-picture-pager and profile-pager tests updated for the tap rule; image-tap, thread-back-scroll pass; thread-reply-test times out on clean HEAD too.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 122 |
1 files changed, 86 insertions, 36 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 7fcce9e..d18f6df 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -10374,9 +10374,41 @@ export function messagesFeature(ctx) { return evt; } + // The thread's replies by parent, each note's answers in the order the + // page draws them (yours first, then oldest first); an orphan hangs off + // the root. + function threadTree(c) { + const parentOf = (ev) => { + // a comment's parent is its lowercase e; one answering the root by + // address (an article) has only an a — the root + if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; + const es = ev.tags.filter((x) => x[0] === 'e'); + const marked = es.find((x) => x[3] === 'reply'); + return (marked || es[es.length - 1] || [])[1] || c.rootId; + }; + const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); + const children = new Map(); + for (const ev of c.replies) { + const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; + if (!children.has(pid)) children.set(pid, []); + children.get(pid).push(ev); + } + for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + return children; + } + // The thread's posts in the order the page shows them: the root, then + // the replies down the tree (a hidden one skipped, its answers kept). + function threadOrder(c) { + const children = threadTree(c), out = []; + if (c.root) out.push(c.root); + const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } }; + walk(c.rootId); + return out; + } function threadScreen() { const s = ui.noteThread; const c = threadFor(s.seed); + queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures // the conversation on screen is the best count of its own replies for (const e of c.replies) noteReply(e); const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)); @@ -10515,23 +10547,9 @@ export function messagesFeature(ctx) { // under it, a little indented, oldest first — except your own, which go // first among a note's answers, so what you just wrote shows right // under the post instead of at the bottom of a long thread. - const parentOf = (ev) => { - // a comment's parent is its lowercase e; one answering the root by - // address (an article) has only an a — the root - if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; - const es = ev.tags.filter((x) => x[0] === 'e'); - const marked = es.find((x) => x[3] === 'reply'); - return (marked || es[es.length - 1] || [])[1] || c.rootId; - }; - const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); - const children = new Map(); - for (const ev of c.replies) { - const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root - if (!children.has(pid)) children.set(pid, []); - children.get(pid).push(ev); - } + const children = threadTree(c); const walk = (pid, depth) => { - const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + const list = children.get(pid) || []; for (const ev of list) { if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place const indent = Math.min(depth, 3) * 14; @@ -11890,11 +11908,22 @@ export function messagesFeature(ctx) { const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; } - // the page the row is on: the feed, or a person's timeline (their - // profile page) — the pager runs over that page's pictures (or clips) + // the page the row is on: the feed, a person's timeline (their profile + // page) or a thread — the pager runs over that page's pictures (or clips) const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread); + const onThreadPage = () => !!ui.noteThread && !onProfilePage(); + // the thread on screen, by its root — the key a thread pager is tied to + const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null; + // a thread as a list the pager can read (feedMediaItems), in the page's order + const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 }); function pageRowOf(el) { - const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; + if (!el || !el.closest) return null; + if (onThreadPage()) { + const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null; + const tid = threadPageId(); + return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null; + } + const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null; if (!rowId) return null; if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; } return feedRowOf(el) ? { c: feed, rowId, profile: null } : null; @@ -11904,6 +11933,13 @@ export function messagesFeature(ctx) { // as the reader nears the end. The media grid's pager has no source here. function pagerSource(p = pager) { if (!p) return null; + if (p.thread) { + // a thread's pictures: the root's and its replies', as the page lists + // them — all here already, nothing more to fetch + const c = threadCache.get(p.thread); + return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true, + live: () => threadPageId() === p.thread } : null; + } if (p.profile) { const c = notesCache.get(p.profile); return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore, @@ -11914,14 +11950,17 @@ export function messagesFeature(ctx) { } // A picture tapped in the feed opens in the media pager, on that picture, // with the feed's other pictures a swipe (or an arrow) away, the way the - // media grid's are. On any other page — a thread, a profile, a chat — the - // picture opens alone, as before. + // media grid's are; on a profile page over that person's pictures, and on + // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever + // a picture is opened from — a thread's opened alone and a tap on it + // closed it, with no caption or controls). Elsewhere — a chat, a face — + // the picture opens alone. function openPicture(url, e) { try { const at = pageRowOf(e && e.target); if (at) { - const it = feedMediaItems(at.c, 'images').find((x) => x.row.id === at.rowId && x.url === url); - if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile, profile: at.profile }); return; } + const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url); + if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; } } } catch {} ctx.openImage && ctx.openImage(url); @@ -11935,12 +11974,12 @@ export function messagesFeature(ctx) { try { const on = pageRowOf(v); if (!on) return false; - const it = feedMediaItems(on.c, 'videos').find((x) => x.row.id === on.rowId && x.url === url); + const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); if (!it) return false; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); - openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile, profile: on.profile, at }); + openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at }); pagerFullscreen(); return true; } catch { return false; } @@ -12098,13 +12137,13 @@ export function messagesFeature(ctx) { // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there - function openMediaPager(kind, start, { fromFeed = false, profile = null, at = 0 } = {}) { + function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) { // the page's own players stop: the pager's is the one to hear for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} } for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) { try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {} } - ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at }; + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at }; render(); } const closeMediaPager = () => ctx.closeLayer('mediaPager'); @@ -12123,6 +12162,15 @@ export function messagesFeature(ctx) { // the pop that closes it lands on a later turn: the glide waits for the feed let tries = 0; const go = () => { + if (src.thread) { + // a thread's post is drawn already (no window): just bring it on screen if it isn't + if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; } + if (!src.live()) return; + const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]'); + const r = el && el.getBoundingClientRect(); + if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8)); + return; + } if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; } landOnPost(src, rowId); }; @@ -12176,10 +12224,11 @@ export function messagesFeature(ctx) { let pagerLoud = true; // sound on, unless the browser said no without a tap function syncMediaPager() { const m = ui.mediaPager; - const want = m && (m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile) + const want = m && (m.thread ? threadPageId() === m.thread + : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile) : ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null; if (!want) { if (pager) dropPager(); return; } - if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager(); + if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager(); if (!pager) buildPager(want); else growPager(); } @@ -12788,11 +12837,12 @@ export function messagesFeature(ctx) { return; } if (pager && pagerSource()) { - // Opened from a post in the feed (or on a profile): a tap on the picture that opened - // it closes it, as a full-screen picture always has. Once the reader - // has moved on, the first tap shows the post's details, and the next - // closes the pager onto that post in the feed. - if (pagerOnStart()) { closeMediaPager(); return; } + // Opened from a post on a page (the feed, a profile, a thread): the + // first tap shows the post's details — its caption and controls — + // and the next closes the pager onto that post. (A tap on the + // picture that opened it used to close it outright, as a full-screen + // picture had; Adam, 2026-10-16: a tap should bring the details up + // wherever the picture was opened from.) if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; } dismissPager(); return; @@ -12800,7 +12850,7 @@ export function messagesFeature(ctx) { el.classList.toggle('show'); if (el.classList.contains('show')) pagerCounts(pager && pager.cur); } - pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, start: want.start, at: want.at || 0 }; + pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 }; pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12826,7 +12876,7 @@ export function messagesFeature(ctx) { pagerArrows(slide); pagerPosition(); if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); } - else if (pager.profile) markSeen(slide._ev); + else if (pager.profile || pager.thread) markSeen(slide._ev); playSlide(slide); pagerCounts(slide); pagerLoadAhead(); |
