aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-14 23:01:38 -0700
committerAdam Soltys <[email protected]>2026-10-14 23:01:38 -0700
commit63d43428468bb5dc336006bc345769b071905204 (patch)
treebc1b7dbaa1fef2e93ae73b0fe2511a23cc0a9807 /src
parent7a3e813d199857ca503864737b7723c7ea916ba5 (diff)
downloadcoinosv3-63d43428468bb5dc336006bc345769b071905204.tar.gz
coinosv3-63d43428468bb5dc336006bc345769b071905204.zip
Profile pages: a picture of theirs opens in the pager, over their timeline's pictures. Tapped on someone's profile page, a picture opened alone in the lightbox (the pager ran only over the feed), so there was nothing to swipe to (Adam). Now the page a row is on says what the pager runs over (pageRowOf, pagerSource): the feed, or this person's timeline — their posts' pictures (and clips) in the timeline's order, nobody else's, more fetched as the reader nears the end (loadOlderNotes, the way the feed pages on); the tap rules are the feed's, and closing after moving on lands the profile on that post (landOnPost judges the page by what is on screen, since a refetch replaces the timeline's cache entry). The profile screen syncs the pager as the feed does. tools/profile-pager-test.js (her pictures only and in order, the end past her last, Escape lands on the post, a tap on the opening picture closes).
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.js90
1 files changed, 60 insertions, 30 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 55deaa1..6d3aefa 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -10614,6 +10614,7 @@ export function messagesFeature(ctx) {
function profileScreen() {
const pk = ui.profilePk;
+ queueMicrotask(syncMediaPager); // a picture of theirs opened full screen: the pager over their timeline
const mine = isMe(pk) || (ctx.shownPubkey && pk === ctx.shownPubkey());
// History can outlive an account switch; only restore our own editor.
if (!mine && ui.profEdit) { ui.profEdit = null; ui.profEditFilled = false; }
@@ -11845,16 +11846,38 @@ 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)
+ const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread);
+ function pageRowOf(el) {
+ const rowId = el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : 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;
+ }
+ // Where a pager opened from a page gets its pictures: the feed on screen,
+ // or a person's timeline — its posts' pictures in its order, more fetched
+ // 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.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,
+ live: () => onProfilePage() && ui.profilePk === p.profile } : null; // (the entry is replaced by a refetch: the page, not the object, says it is still there)
+ }
+ if (p.fromFeed) { const c = feedNow(); return { c, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderFeed(), done: () => !!c.end && c.shown >= c.notes.length && !c.loadingMore, live: () => c === feed && !onProfilePage() }; }
+ return null;
+ }
// 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.
function openPicture(url, e) {
try {
- const rowId = feedRowOf(e && e.target);
- if (rowId) {
- const it = feedMediaItems(feed, 'images').find((x) => x.row.id === rowId && x.url === url);
- if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; }
+ 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; }
}
} catch {}
ctx.openImage && ctx.openImage(url);
@@ -11866,14 +11889,14 @@ export function messagesFeature(ctx) {
// himself — so it follows the phone throughout.
function openClip(v, url) {
try {
- const rowId = feedRowOf(v);
- if (!rowId) return false;
- const it = feedMediaItems(feed, 'videos').find((x) => x.row.id === rowId && x.url === url);
+ const on = pageRowOf(v);
+ if (!on) return false;
+ const it = feedMediaItems(on.c, 'videos').find((x) => x.row.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: true, at });
+ openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile, profile: on.profile, at });
pagerFullscreen();
return true;
} catch { return false; }
@@ -12031,13 +12054,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, at = 0 } = {}) {
+ function openMediaPager(kind, start, { fromFeed = false, profile = 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, at };
+ ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, at };
render();
}
const closeMediaPager = () => ctx.closeLayer('mediaPager');
@@ -12047,17 +12070,17 @@ export function messagesFeature(ctx) {
// the feed on the post of the picture last looked at; otherwise (the
// media grid's pager, or nothing moved) it simply closes.
function dismissPager() {
- if (pager && pager.fromFeed && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId);
+ if (pager && pagerSource() && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId);
else closeMediaPager();
}
function closePagerTo(rowId) {
- const c = feedNow();
+ const src = pagerSource();
closeMediaPager();
// the pop that closes it lands on a later turn: the glide waits for the feed
let tries = 0;
const go = () => {
if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; }
- landOnPost(c, rowId);
+ landOnPost(src, rowId);
};
requestAnimationFrame(go);
}
@@ -12068,12 +12091,14 @@ export function messagesFeature(ctx) {
// draw, and set the row under the top again each frame — until it has sat
// still a moment, a couple of seconds have gone, or the reader touches the
// page. A jump, not a glide: the pager was covering the feed.
- function landOnPost(c, rowId) {
+ function landOnPost(src, rowId) {
const MARGIN = 8, t0 = Date.now();
const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(rowId) + '"]');
let still = 0, misses = 0, lastY = null;
const step = () => {
- if (c !== feed || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return;
+ if (!src.live() || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return;
+ // the list as it is now (a profile's entry is replaced by a refetch)
+ const c = src.profile ? notesCache.get(src.profile) || src.c : src.c;
// the page sent somewhere else meanwhile (not by a finger: a glide, a link)
if (lastY !== null && Math.abs(window.scrollY - lastY) > window.innerHeight / 2) return;
const el = find();
@@ -12106,9 +12131,11 @@ export function messagesFeature(ctx) {
let pager = null; // the live pager: { el, scroller, kind, keys, cur, loud, io, near, tick }
let pagerLoud = true; // sound on, unless the browser said no without a tap
function syncMediaPager() {
- const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null;
+ const m = ui.mediaPager;
+ const want = m && (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)) dropPager();
+ if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null))) dropPager();
if (!pager) buildPager(want);
else growPager();
}
@@ -12686,8 +12713,8 @@ export function messagesFeature(ctx) {
else if (pager.cur) playSlide(pager.cur);
return;
}
- if (pager && pager.fromFeed) {
- // Opened from a post in the feed: a tap on the picture that opened
+ 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.
@@ -12699,7 +12726,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, 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, start: want.start, at: want.at || 0 };
// The end of the pager: swiping past the last slide lands here, on
// something saying more is coming (not a nudge that goes nowhere) — and
// asks for it at once. Slides that arrive go in above it; one arriving
@@ -12721,7 +12748,7 @@ export function messagesFeature(ctx) {
const v = pager.cur && pager.cur.querySelector('video.mp-media');
if (v) v.pause();
el.classList.remove('show');
- if (pager.fromFeed) pagerFeedAhead(); else crawlMedia().catch(() => {});
+ if (pagerSource()) pagerFeedAhead(); else crawlMedia().catch(() => {});
pagerTailState();
}
}, { root: scroller, threshold: [0, 0.6] });
@@ -12743,17 +12770,18 @@ export function messagesFeature(ctx) {
const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause();
// how long a clip held the reader (the head start's measure of them)
if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; }
- if (pager.fromFeed && video) el.classList.remove('show');
+ if (pagerSource() && video) el.classList.remove('show');
}
pager.cur = slide;
pager.anchor = slide;
pager.curAt = Date.now();
pagerArrows(slide);
if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); }
+ else if (pager.profile) markSeen(slide._ev);
playSlide(slide);
pagerCounts(slide);
// near the end: ask the feed for more, the pager picks them up
- if (pager.fromFeed) pagerFeedAhead();
+ if (pagerSource()) pagerFeedAhead();
else { const gs = pagerGroups(); if (gs.indexOf(slide.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); }
};
pager.io = new IntersectionObserver((entries) => {
@@ -12808,15 +12836,16 @@ export function messagesFeature(ctx) {
const PAGER_AHEAD = { images: 4, videos: 6 };
let pagerFeeding = false;
async function pagerFeedAhead() {
- if (pagerFeeding || !pager || !pager.fromFeed) return;
+ const src = pagerSource();
+ if (pagerFeeding || !src) return;
pagerFeeding = true;
try {
- const c = feedNow();
- for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) {
+ const c = src.c;
+ for (let pages = 0; pages < 8 && pager && pagerSource(); pages++) {
const gs = pagerGroups();
if (gs.length - 1 - gs.indexOf(pager.cur && pager.cur.closest('.mp-group')) > (PAGER_AHEAD[pager.kind] || 4)) break;
const shown = c.shown, held = c.notes.length;
- await loadOlderFeed();
+ await src.more();
growPager();
if (c.shown === shown && c.notes.length === held) break;
}
@@ -12831,7 +12860,8 @@ export function messagesFeature(ctx) {
const waiting = pager.onTail;
let first = null;
pager.groups ||= new Map();
- for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) {
+ const src = pagerSource();
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -12873,8 +12903,8 @@ export function messagesFeature(ctx) {
// more to give — that this is everything
function pagerTailState() {
if (!pager || !pager.tail) return;
- const c = feedNow();
- const done = pager.fromFeed ? !!c.end && c.shown >= c.notes.length && !c.loadingMore : !!mediaIndex(c.id).end;
+ const src = pagerSource();
+ const done = src ? src.done() : !!mediaIndex(feedNow().id).end;
if (pager.tail.classList.contains('done') === done) return;
pager.tail.classList.toggle('done', done);
pager.tail.querySelector('.mp-tail-text').textContent = done ? t('pagerCaughtUp') : t(pager.kind === 'videos' ? 'pagerFetchingVideo' : 'pagerFetchingPictures');