aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
* Closing the pager (or a picture) lands on the page under it, not wherever ↵HEADmasterAdam Soltys27 hours4-4/+179
| | | | | | | | | | | | | | Back goes The × was a blind history.back(), and the entry under the pager isn't always the feed: the browser's older entries outlive a background reload of the app, and boot merges entries. It took the reader to the wallet, then the chat list. closeLayer still steps Back (so the phone's Back doesn't reopen it), but if Back lands anywhere but the page under the layer, the reader is put on that page and that entry rewritten to it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed clips: the player's full-screen button works again, and opens the pagerAdam Soltys27 hours2-6/+24
| | | | | | | | | controlslist=nofullscreen kept Chrome's full screen (no next clip) off the feed, but greyed the button out. The button is back; pressing it backs out of Chrome's full screen and opens the pager on that clip instead. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Pager swipes: follow the finger sooner, page on a shorter swipe, glide ↵Adam Soltys28 hours2-15/+31
| | | | | | | | | | | | | | | | faster, no step back - The glide was timed from now, but a frame's time can be a little earlier: its first frame stepped back (72px -> 58) before going on, the choppy 'reset' on letting go. Timed from its first frame now. - Slides follow the finger after 10px (was 30: a dead zone, then a jump). A touch that comes and goes within 24px and 350ms is still a tap. - On letting go: on to the next past a tenth of the width across, an eighth of the height down, or a flick of 24px at 0.2px/ms (was a fifth, 48px, 0.3). - Glide at most 130ms (was 170). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Pager glide: land on the slide itself, no overshoot and snap-back; quickerAdam Soltys33 hours2-8/+38
| | | | | | | | | | The glide aimed at n x the scroller's height, but a post is 100dvh, a hair off it on a phone; the error grew post by post until it landed past the slide and the snap yanked it back. It now aims at the measured position of the next slide. Faster too: at most 170ms (lands in ~195ms from lift-off). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Pager: a snappy glide to the next slide, not the browser's slow smooth scrollAdam Soltys33 hours2-7/+36
| | | | | | | | | Letting go of a swipe (or an arrow key) runs the pager's own eased-out glide, at most 220ms and quicker after a fast flick; a finger catching it mid-glide takes it from there. A swipe now lands in ~240ms (was ~425ms). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Profiles show boosts as boosts, and their saved copy keeps each post's kindAdam Soltys38 hours2-4/+90
| | | | | | | | | | A boost that reached a profile from the feed was saved as a kind 1 note, so reopened it drew its raw JSON as a reply; video events and polls lost their kind and tags the same way. The saved copy now keeps kinds (boosts whole), old copies are dropped, and profiles ask for boosts too. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Android 1.14 (code 15) APK: UnifiedPush only without Play Services, built ↵Adam Soltys45 hours1-0/+0
| | | | | | | and signed on lap from 90bc4d3 (76:64:83:AB…). sha256 58a5c4787efbb52d3b34924e51ede844c3b17d8e20b5fc6524e24fd59f5bdfc1. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Android 1.14 (code 15): UnifiedPush only where web push can't reach the app. ↵Adam Soltys45 hours4-4/+28
| | | | | | | isolabellart asked why Coinos opens a prompt inside ntfy: every launch of the TWA asked whatever UnifiedPush distributor was installed for an endpoint (and asked for notification permission for it), Play Services or not. UnifiedPush is there for phones without Google Play Services (GrapheneOS and the like); with Play Services, web push reaches Chrome — and so the TWA — through Google already, and asking ntfy too only put up its prompt (and would bring every notification twice). The TWA now asks a distributor only when Play Services isn't installed and enabled (Push.playServices; its package named in the manifest's queries for Android 11+), or when it already holds an endpoint — whoever has one has been through the prompt and may be counting on it. The WebView builds (webview, fdroid) are unchanged: web push never reaches a WebView, so UnifiedPush is their only way in either way. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Video: the screen is never turned for a wide clip. Adam would rather a wide ↵Adam Soltys45 hours3-64/+34
| | | | | | | clip play with bars, and turn the phone himself when he wants it bigger. The pager no longer locks the screen to landscape for a wide clip (in the installed app or full screen in a tab), so it follows the phone throughout; in the installed app the pager no longer falls back to the browser's full screen either (that was only to be allowed to turn the screen). A thread's clip and a YouTube clip still go full screen on a tap, without the turn. feed-video-pager-test and fullscreen-notice-test check that nothing asks the screen to turn. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: a tap is a tap even if the finger moves a little, and a swipe ↵Adam Soltys45 hours2-5/+224
| | | | | | | has to mean it. Adam tapped a clip to pause it and it was taken for a swipe: the slides scrolled natively, and the browser takes a touch that moves a few pixels for a scroll — no click, so no pause — while a quick one flung the pager on to the next slide (reproduced: an 18px drift paged on). Holding the scroll back after the fact doesn't work (the fling comes after touchend), and stopping the first move stops the browser scrolling for the whole touch, so the pager now judges and moves touches itself: the slides hold still until a touch has gone 30px — short of that it's a tap, done here when the browser sent no click (a button under it is pressed), and a later click isn't counted twice — and past it they follow the finger the way it set off (up/down post to post, across within a post, snap off while dragging), going on to the next on letting go if the finger went a fifth of the screen or flicked (0.3 px/ms over at least 48px), else back. Touches on the seek bar, in an opened caption, or on a zoomed-in picture are left to those. And the arrow keys go to the slide or post next to the one on screen, by name, not "a screen further" — from a scroll still settling that landed short (quote-pager-test failed two runs in three). tools/pager-tap-slop-test.js (drifts of 18px and 14px and a quick 25px flick: taps — paused, played, not paged; a 320px swipe up and a 300px swipe down page; a slow 70px drag springs back; a quick 80px flick pages). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Android 1.13 (code 14) APK: sticky-immersive, built and signed on lap from ↵Adam Soltys45 hours1-0/+0
| | | | | | | bcf5f54 (the key assetlinks.json names: 76:64:83:AB…). sha256 63dac2e8b5b6b3d2d4b1f0fe4d73daa3c4c42b23bd11c81ba0d47da483efa26c. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Android 1.13 (code 14): the app runs immersive. Adam doesn't want Chrome's ↵Adam Soltys45 hours2-2/+12
| | | | | | | "v3.coinos.io – To exit full screen, drag from the top and touch the back button", which Chrome shows whenever a page enters its full screen and a page can't turn off. The TWA now launches in sticky-immersive display mode (android-browser-helper's DISPLAY_MODE): the status and navigation bars are hidden, and a swipe in from an edge shows them for a moment. The page — in display-mode fullscreen, which installedApp() counts — fills the screen with the pager without ever asking for the browser's full screen. A Custom Tab's window is the browser's, so the page can't switch this on and off: it's the whole app. Thread clips and YouTube still ask for the browser's full screen, and so still get the notice. 1.12 (code 13) was published without its APK committed here; this sits above it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: one clip plays — nothing under it starts, and nothing beside ↵Adam Soltys45 hours2-0/+136
| | | | | | | it. Adam turned his phone while watching a clip and heard it start again from the beginning while the picture played on: a second copy playing unseen. Not reproduced here (a turn in the test browser, with the clip muted or unmuted in the feed first, on this build or the last, leaves one clip playing), but three ways to a second copy are now closed. The feed under the pager stays laid out, and its autoplay watched it: anything there coming into view — a turn re-lays the page out — started, the pager's own clip as often as not, with sound if it had been unmuted there (with the guard off, the test's scroll under the pager starts the feed's copy). Under the pager the feed's autoplay now starts nothing, and opening the pager pauses every player on the page (YouTube too). In the pager only the slide on screen may start (not one a half-way layout passed over, whose start was still on its way), and starting one pauses any other. tools/pager-rotate-test.js (installed app, no full screen: one clip through a turn and back, on the slide it was on; a feed clip brought into view under the pager stays still; muted and unmuted in the feed first). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: up and down go from post to post, left and right along a post's ↵Adam Soltys45 hours9-67/+152
| | | | | | | pictures. Adam: swipes and arrows up/down should go to the next post's media, left/right to the next within a post. The pager is now posts stacked top to bottom, each a row of its pictures or clips side by side — a quoted post's join the post that quotes it — with dots along the top when a post has more than one, saying how many and which. Up and down (swipe, arrows, j/k, Page) move a post at a time and land on a post where its row was left; left and right (swipe, arrows, h/l) move within the post and stop at its ends. A post's pictures all load as it comes near (those along its row too); a clip's head start goes to the next post's (where its row rests), the next along this one, and the post after next, at half; the look-ahead (the feed paging on, the media crawl) counts posts. Pictures pan sideways as well as up and down at 1×. Tests find the slide on screen by where it sits (the pager's children are posts now); feed-picture-pager walks right within a post, down past one with none, back up to where a post was left, left to its first, and a sideways swipe next to a swipe up; quote-pager walks the quoting post's row (its own picture, then the quote's). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed pager: pictures (and clips) in a quoted post are in it too. Adam ↵Adam Soltys45 hours2-5/+148
| | | | | | | full-screened the screenshots in a quoted post and couldn't swipe on: a tap on a picture in the feed opens the pager on it only if it is among the pager's slides, and those were each feed post's own pictures — a quoted post's are its own, drawn inside the one quoting it, so the tap fell back to the picture alone (the lightbox), with nothing to swipe to. Now the pager takes, after each post's own pictures (or clips), those of the posts it quotes, as far as they've been fetched (and not hidden or folded as sensitive): swipes walk through them and on to the next post's, the slide's details name the quoted post's author and words, and closing lands on the post that quoted it. tools/quote-pager-test.js (a post with its own picture quoting one with two, served only by a relay: the tap opens the pager on the quoted picture, down through the quote and on, up to the quoting post's own, the quoted author in the details, Escape onto the quoting post) — with the quotes left out it fails as Adam saw, the picture opening alone. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: the head start sizes itself to the clip and the link, and a tap ↵Adam Soltys45 hours3-28/+94
| | | | | | | pauses any playing clip. Adam: a fixed 512 KB head start isn't right for every clip; and a tap anywhere on a playing clip but the seek bar should pause it. The head start is now seconds of the clip, not bytes: two seconds' worth on a link well ahead of the clip's bitrate, four when only just ahead, and on a link slower than the clip enough to cover the shortfall over its length — at least 256 KB, at most 4 MB, the second clip ahead half. The bitrate is the file's size over its length when the post says both (imeta size and duration; duration is now kept), else guessed from its picture size, else a phone clip's; the link's speed is measured off the head starts themselves (a moving average), and until there is a measure it's two seconds' worth — Chrome's own downlink guess runs low (in the test browser it would have had every first head start at the cap). A reader who swipes on within seconds (a moving average of how long clips hold them) gets just the opening; a clip of 3 MB or less comes whole. A tap on a playing clip in the media grid's Videos pager now pauses it with its details up, as one opened from the feed does; another plays on (it only toggled the details). And mergeFeed's duplicate check now counts the posts waiting a moment to go in together: a copy from a second relay, or another version of an addressable clip, could slip in during that window (video-events-test caught the second, one run in a few). pager-eager-test: clips whose imeta states size and length; two seconds' worth ahead (half for the second) before the link is measured, still that on a fast link measured, the cap (half) on a 3 Mbps link the server paces (the player still takes the opening from the cache there — DevTools' throttling had defeated the cache, not the app); feed-media-views-test checks the tap to pause and play on in the Videos pager. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed: NIP-71 video events are posts, with their clips. Adam asked whether a ↵Adam Soltys46 hours2-16/+202
| | | | | | | feed could ask for just the posts with videos; for notes it can't (a relay filters by kind, author, time and one-letter tags, never by what a post says, and imeta isn't indexed), but NIP-71 gives clips kinds of their own. Of the 900 people he follows, 58 have posted 665 of them (191 kind 21, 33 kind 22, 170 kind 34235, 271 kind 34236 — divine.video's), 12 in the last fortnight next to 220 notes linking a video. Those kinds are now asked for with the notes (FEED_KINDS: feeds, profiles, topics, live), and drawn as posts: the title, the words, and the clip from its imeta — of the versions on offer, the one nearest 1080 on its short side (not the 4K original), never an HLS playlist, its url tag as a fallback — with the imeta's poster and size. A file whose name doesn't say it's a clip (divine.video's have no extension) plays when an imeta types it video/* or it's a video event's file, in a note as much as a video event. An addressable clip (34235/34236) shows once, not once per version. The clips are in the pager and the media grid's Videos (their captions: title and words, no file link); replies are NIP-22 comments, which threads already fetch. tools/video-events-test.js (the kinds asked for; a kind 21 as a post with its 720p version and poster, not "kind 21"; a 34236 with an extensionless file and its older version once; a note's imeta-typed file; the pager over all three with the caption). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Chat header: Add person is an icon. Adam's screenshot of the Bitcoin ↵Adam Soltys46 hours2-7/+15
| | | | | | | Community header: "Add person" wrapped onto two lines in a box twice the height of the bell and the door beside it. It is now a person with a plus, the same size and stroke as those (and lit while its panel is open), with "Add person" as its label and tooltip — in a community's header and a private group's. The form's own Add person button keeps its words. chat-layout-test finds the button by its label too; SHOT_PHONE=path saves the phone-width header. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed video: fewer "To exit full screen" notices, and a YouTube clip takes ↵Adam Soltys46 hours5-10/+294
| | | | | | | one tap. Adam: Chrome's "v3.coinos.io – To exit full screen, drag from the top and touch the back button" shows every time, and a YouTube clip needs its own small full-screen icon. The notice is Chrome's, put up whenever a page goes full screen; a page can't turn it off, so the app goes full screen less. In the installed app (display-mode standalone, or opened from an android-app:// referrer) there is no address bar and the pager already fills the window, so it no longer goes full screen: a wide clip asks the screen to turn without it, and only if that's refused (the browser may allow turning only in full screen) goes full screen — the notice then, but the clip on its side; the screen is let go past that clip, or on closing. A browser tab goes full screen as before. A feed clip's own control bar loses its full-screen button (controlslist=nofullscreen): a tap on the clip is the way, into the pager. YouTube: the player's taps never reach the page, so a layer over a playing clip's picture takes them, leaving YouTube's control bar along the bottom to YouTube; one tap on it — or on the still — plays with sound and takes the clip's box full screen, turned on its side (unMute + playVideo through the iframe API for a player autoplaying muted); full screen the layer steps aside, so all of YouTube's controls are there. YouTube can only fill the screen through Chrome's full screen, so its notice still shows there. tools/fullscreen-notice-test.js (installed app: no full screen, the screen turned and let go, the fallback when turning is refused; no full-screen button on feed clips); tools/youtube-tap-test.js (YouTube stood in for: autoplaying muted, a tap on the control bar stays YouTube's, one tap full screen with unMute and playVideo, the layer aside full screen and back after; with Data Saver, the still to full screen with sound); video-unmute-test stubs ui for its slice of videoNode. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: past the last slide, a "fetching the next video" screen — and ↵Adam Soltys46 hours26-38/+345
| | | | | | | the next one, when it comes, is moved onto. Adam reached the last clip before the next was in, and a swipe only nudged the clip and sprang back. Now the pager ends in a screen of its own: a ring sweeping round, three sparks orbiting the other way, the play mark (a picture mark in a pictures pager) breathing in the middle, and "Fetching the next video…" (or "Fetching more pictures…") with a sheen running through it, all still under prefers-reduced-motion. Landing there stops the clip left behind and asks for more at once (the feed pages on; the media grid's pager crawls); slides that arrive go in above it, and one arriving while the reader waits is moved onto and plays. With nothing older to be had (the feed at its end, or the media index), it says "That's everything for now" and goes still. The scroller no longer anchors (overflow-anchor: none), so a slide put in above the screen doesn't hold the screen in view. Strings in all 20 languages. tools/pager-tail-test.js (relays that take 3 s for anything older: the screen at the end, the clip behind stopped, the next clip moved onto and playing, the end saying that's everything, Escape onto the last clip's post); feed-pager-ahead and feed-media-views count slides without it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: every clip plays when it comes up, and the next two get a ↵Adam Soltys46 hours3-9/+195
| | | | | | | half-megabyte head start. Adam: flipping through clips should just play them, large ones too, and load a bit of what's next — not the whole of it. A clip over the size budget (or one whose size the host won't say: most, behind CORS) used to wait in the pager under a tap-to-play cover, and even a small one waited on its size probe (up to 5 s of HEAD) before playing. Now, on an ordinary connection, the pager plays the clip on screen at once, streaming as it goes, and fetches the opening 512 KB of the next two into the browser's HTTP cache with a range request (a host that ignores the range has the rest cancelled); the player, given the file when it's swiped to, takes its opening from the cache and streams on from there (the test's server sees its first request start at byte 524288; playing ~300 ms after the swipe). The next clips get no player of their own beforehand: one given its file, even with preload=metadata, took the whole 34 MB on a fast link. With Data Saver on or a slow link (2g/3g), the pager is careful as before: a clip over budget waits for a tap and nothing loads ahead. tools/pager-eager-test.js (35 MB clips: covered in the feed, played in the pager, 512 KB each ahead and nothing more, the cache reused; Data Saver: nothing ahead, the cover); feed-media-views-test moves its tap-to-play checks onto a slow link and checks the 50 MB clip plays on an ordinary one (its index-last probe check fails on HEAD before this too). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Media pager: the seek bar sits clear of the phone's gesture strip. Adam's ↵Adam Soltys46 hours2-5/+15
| | | | | | | screenshot: the bar ran along the very bottom edge of the screen, under Android's own gesture handle — a drag there is the system's (home, recents), and it was placed by env(safe-area-inset-bottom), which is 0 here (the page doesn't ask for viewport-fit=cover, and the gesture strip is never reported as a safe area anyway). It now sits 32px up from the bottom (plus any safe-area inset) and 16px in from each side, where an edge drag is the back gesture; the post's details stand above it, and its line has rounded ends. feed-video-pager-test checks the bar's place (32px up, 16px in) and that the author, caption and counts clear it; SHOT=path saves a picture of the pager with the details up. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed pager: closing it lands on the post of the slide last looked at, and ↵Adam Soltys47 hours2-10/+59
| | | | | | | the clips ahead are looked for sooner. Adam swiped to the last clip (one more loaded, slowly), closed the pager and the feed was somewhere else. Closing used glideToPost: for a row never drawn — a post the pager went on to, maybe paged in while it was open — it guesses the place from the rows' average height, draws there, and glides once to wherever the row then is; rows of very different heights (a long post, a clip finding its shape) put the guess far off, and the glide was never corrected as they settled (in the test: 325px past it; a row still undrawn after the guess sent the feed to the top). Now landOnPost jumps (the pager was covering the feed), draws, and sets the row under the top again each frame until it has sat still a moment, 2.5 s have gone, the reader touches the page, or the page is sent elsewhere. And the pager keeps more ahead before paging the feed on: six slides for clips (fewer and further between, each watched a while), four for pictures (was three). feed-pager-ahead-test: some of the words-only posts are long now, and closing on the second-last picture checks the feed sits on its post after the pictures above have loaded (fails before: row top -325). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed video: a touch on a clip that has been playing opens the pager. Adam ↵Adam Soltys47 hours2-7/+149
| | | | | | | tapped a feed clip away from its play button and nothing happened (his screenshot: the clip paused, the player's bar up — 0:09 / 0:18). Once a clip has played its own controls are up, and Chrome on Android keeps a tap on such a clip for them (it shows or hides the bar): the page gets the pointer events but never the click, and the click was all videoTap listened for — so the player's full screen before, and the pager since b01ec80, were out of reach by touch. Now a touch that lifts where it went down, within half a second, is taken at pointerup; the control bar and a paused clip's play button stay the player's, a drag stays a scroll. Where the click does come after, it is spent: on the clip (no thread opened, no second pager) and on the pager it opened (which would otherwise pause the clip and raise the details). tools/video-touch-tap-test.js plays the phone out by swallowing the click on a clip with controls: the touch opens the pager; the play button, the bar and a drag open nothing; with the click let through, one pager, no thread, the clip playing with nothing raised. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed: a post you've read isn't boosted back to the top. Adam found Gigi's ↵Adam Soltys47 hours2-1/+225
| | | | | | | post (read several times that day) at the head of his feed under "47 reposted": a repost takes its place in the feed by when it was boosted, and the only check against showing a post twice was the feed's own window — the 80 posts in memory, the 30 cached — so once the copy he'd read was out of it (a reload, a long scroll), the next boost brought the post back as new. Now a post is remembered as read on this device (per wallet, two weeks, 5000 at most) once its row has held half itself, or half the screen for a tall one, in view for a second, or it was shown in the media pager — remembered by the row it was read in (the post, or the repost it came under). A later repost of it under any other row stays out of the feed (mergeFeed); the row that was read keeps its place, so scrolling back still finds it, and your own reposts show. tools/feed-reboost-test.js (read after a second in view, not at a glance; a live boost of the read post stays out while one of an unread post lands; after a cold reload another boost of it stays out and the post is where it was) — with the check off it fails as Adam saw, the post at the top under both boosts. feed-stability's two failures are on HEAD before this too. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed pager: nearing the last slide pages the feed on, so the pictures and ↵Adam Soltys47 hours2-3/+171
| | | | | | | clips keep coming. Adam swiped to the last picture of a pager opened from the feed and it simply ended: that pager walks the feed's own posts, which page in ten at a time as the feed is scrolled, and swiping isn't scrolling — its "near the end" ask went to the media grid's crawl, which this pager doesn't read. Now, with three slides or fewer past the one on screen (on arrival, and on the pager's 1.5 s tick if a page came back empty or the feed was busy), the feed pages on — the posts it holds, then older ones from the relays (loadOlderFeed) — and the pager grows, several pages in a row when those posts carry nothing to show, until it is more than three ahead or the feed is at its end. tools/feed-pager-ahead-test.js: 26 pictures across 41 posts with 15 words-only posts in the middle and the older ones only on a stand-in relay (the feed's cache keeps 30); stepping through reaches every picture, in the feed's order. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed: a clip opens in the media pager, full screen, with the feed's other ↵Adam Soltys47 hours5-31/+377
| | | | | | | clips a swipe away and a seek bar to drag. Adam full-screened a video in the feed and swiped up for the next one, and nothing moved: a tap on a feed clip handed it to the player's own full screen (video.requestFullscreen), where every touch is the player's and there is no next. Now, as pictures do since 6eda018, the tap opens the media pager on that clip over the feed's clips in the feed's order (every shape, not only the tall ones the Videos view flips through), carrying on from where it was. What the player's full screen gave is kept: the pager puts the page itself full screen (the tap's own gesture), and a wide clip locks the screen to landscape — for that clip; past it the screen follows the phone. Each clip in the pager (the media grid's too) gets its own seek bar, a thin line along the bottom that thickens with the details up; dragging it scrubs with the time shown, and the drag is never the pager's swipe or tap. A tap pauses the clip and shows the post's details, the × and the bar; another plays on. Leaving full screen another way (Back on Android, Escape) leaves the pager in the same step; × or Escape after moving on lands the feed on the post last watched. Threads, profiles and chats keep the player's full screen. tools/feed-video-pager-test.js (open, resume, landscape lock and release, seek drag, tap pause/play, swipe and arrows, Escape onto the post, exit full screen); video-tap-test now checks the feed opens the pager and runs its native full-screen checks in a thread. tools/puppeteer.js takes CHROME=/path for a machine without /usr/bin/google-chrome. feed-media-views-test's range-less probe check fails on HEAD before this too. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01KPw21NEpWeTGu6swVASmBt
* Feed wheel: a turn back against the coast hunts, in short steps that grow. ↵Adam Soltys3 days2-20/+65
| | | | | | Flying down the feed and turning the wheel back for the post just passed used to send the page flying the other way (a full notch's coast, 300px) the moment it caught — the reader overshoots again, and hunts by overshooting. Now a reversal against a coast (or one that died within 400 ms) catches the page and takes that notch as a quarter of its travel, over a quick coast (110 ms, not 325); each notch on in the same direction grows the step — 0.35, 0.5, 0.7, 0.85 of a notch — and the sixth is a full push with the usual wind-up, so a few flicks have the wheel flying again. Another turn back while hunting starts the small steps over, and two seconds without a notch ends the hunt: a notch from rest is the push it has always been. Keys, a touch or a grab end it too. tools/feed-wheel-reverse-test.js (short step on reversal, the ramp, the restart, the pause, the just-died coast, the full push from rest); feed-momentum-test still passes. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: your newest post is in Following from the first ask, and a new post ↵Adam Soltys3 days2-19/+143
| | | | | | goes to the feeds it belongs to. Adam posted from the laptop with the #photography feed open and found the post in #photography (untagged) but not in Following, on either device, until a later refresh. Two causes. The feed's opening ask (prefetchFeed, and seedOtherFeeds) took only the first 400 of its authors, and your own key was added after the follows — with 898 follows it sat in the third chunk, so the opening ask never named you; the later refresh (feedPass, every chunk) did. Your key now leads the author list, and the opening ask covers every author, a chunk per ask. And publishPost put the provisional post at the top of whatever feed was open: now it goes to the feeds it would reach — the open feed and Following when they carry your posts (Following, a people feed with you in it, Everything) or a topic the post wears — swapped for the signed event or withdrawn in each. The hashtags in the text go out as t tags (NIP-24, up to the hashtag-spam count), so topic feeds here and elsewhere find the post. tools/own-post-feeds-test.js (450 follows: every one asked, your key first; a post from Following; an untagged post from a topic feed stays out of it; a tagged one lands in both). Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: a picture opens in the media pager, with the feed's other pictures a ↵Adam Soltys3 days6-15/+220
| | | | | | swipe away. Tapping a picture in a feed row used to open it alone (the lightbox); it now opens the media grid's pager on that picture, over the feed's pictures in the feed's own order — every picture of a post before the next post's (a repost's inner post, the feed's moderation) — so a swipe up or down, or any arrow key (up/down as before, now left/right too), steps to the next or previous one. A tap on the picture that opened it still closes it, as a full-screen picture always has; once the reader has moved on, the first tap shows the post's details and actions, and the next (or Escape, or the ×) closes the pager with the feed landed on that post (glideToPost, once the pop has brought the feed back). The pager's pictures pinch to zoom (attachZoom, now exported, without its wheel — the wheel is the pager's; at 1× one finger is the pager's swipe). Pictures in threads, profiles and chats still open alone. tools/feed-picture-pager-test.js; image-tap-test and lightbox-scroll-test accept the pager as the full-screen picture. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: closing a full-screen picture leaves the feed where it was. The scroll ↵Adam Soltys3 days2-9/+123
| | | | | | memory that puts a reader back on the row they left when Back closes a thread or profile fired on every history pop, and a remembered place goes stale whenever its page is left another way (the Feed button from a thread), so a photo's × jumped the feed to wherever an earlier thread had been opened from, or to its top (Adam's recording, 2026-10-11 22:31, at the 10 s mark; reproduced on his tab: 2500 → 22361). Now the place is restored only when a pop actually leaves a thread or profile — the page being left is read in a capture-phase popstate listener, before the app's own handler re-renders — and the entries above the one it matches are dropped as stale. tools/lightbox-scroll-test.js (stale place from a thread left by the Feed button, a picture opened and closed, Back out of a thread still landing on its row). avatar-profile-test fails on HEAD before this too. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: ⚡ on a locked wallet says so and opens the Accounts page. A locked ↵Adam Soltys3 days3-1/+77
| | | | | | wallet — the watch-only view of a sealed vault, or a nostr identity signed in over one (Adam's debug Chrome: three watch-only accounts, the vault gone, his extension identity on the first) — has no keys to pay with, and the zap button knew it: it got no handler at all, so the tap pressed the icon and nothing more, under an arrow cursor. Now the tap toasts "Your wallet is locked, so it can't zap. Unlock it to send sats." and opens the Accounts page, where the padlock and Add account are; the open feed is remembered for the way back (by URL, or by id for Following, which has none — returnAfterSignin learns the id form). tools/zap-locked-test.js. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed menu: Delete feed sits beside Edit. Dropping a feed you built meant ↵Adam Soltys3 days2-5/+10
| | | | | | opening its editor first (a saved timeline already had the drop in the ⋯ menu, having nothing to edit); the menu now offers Delete feed for every feed of yours, Edit above it where there is something to edit. The editor keeps its own Delete feed. feed-custom-test deletes "Coin talk" from the menu. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: a picture from a host that won't hand over its bytes paints from a ↵Adam Soltys3 days4-18/+302
| | | | | | server-made copy. The app shrinks a post's photo to the column's size itself (an 1100px copy made once, kept on disk) — but only when the host allows CORS; one that doesn't (primal's blossom, nostrcheck, most personal sites) left the <img> painting the original, and a 9528×6328 camera file froze a wheel scroll for 400 ms on its texture upload. vmedia now has /vmedia/pic.webp?s=col|tile&u=<picture>: the picture at 1100px wide (or a 360px square of its middle for the grid), made with libvips (shrink-on-load, so the original is never decoded whole; turned the way its EXIF says; alpha kept; stripped) as WebP, cached on disk and at the CDN for everyone. The usual guards: https on public addresses only, each redirect's host checked, 60 MB cap, the bytes sniffed (a GIF is refused — its copy would be a still), a few jobs at a time, 600 new copies per address per hour, an 8 GB cache. The client asks the server for the column copy when the host is known to keep its bytes (or has just refused, or there's no worker to make one here), keeps it in the same cache as the copies it makes, and takes the server's tile for the grid the same way; a picture the server has no copy for paints as before. The container gets libvips-tools (Dockerfile). tools/pic-proxy-test.js (canned server answers). Server measured on cs: a 3072×4608 photo in 0.5 s the first time, a millisecond after; feed-stability's two pre-existing asserts still fail on 491904e. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: the wheel coast no longer stalls or reverses as pictures land. A row ↵Adam Soltys3 days2-14/+188
| | | | | | that painted before its picture's size was known (a cached row on a feed just opened, a link card, a clip learning its shape) grew after it was measured, and the stale height is what the spacer took when the row left the window: the page shrank by the difference, the browser's scroll anchoring pulled the reader back by as much, and a coasting scroll lost ground (or ran backwards) right as the media appeared — on a feed opened fresh, 18 such frames in four seconds. Rows are now watched by a ResizeObserver (after layout, nothing forced) so the heights stay live; originals decode async so a big one never holds a paint. Tried and dropped: shrinking no-CORS originals through createImageBitmap — it re-decodes on the main thread. tools/feed-scroll-jank.js grows a hands-off coast scenario (--coast: quick-notch bursts, then let go; dips/jumps in the coast's own travel next to what landed: media loads, layout shifts, row batches, page height, scrollTo/scrollBy callers), --trace (a Chrome trace: the browser's work inside each long frame — decode, layout, paint, GC — and the compositor's dropped-frame count), --url for a never-seen topic feed, --only settled|coast, --start px. Measured: the fresh-feed coast went from 18 dips to 8 small ones; a fresh topic feed coasts with one 33 ms frame in five seconds. Still open: a 9528×6328 original from a host without CORS (primal's) once froze a scroll step for 400 ms on its texture upload — only a resizing proxy fixes that one. feed-stability's "a small feed query starts before opening the feed" and "an unavailable image remains a usable link" fail on 491904e too. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed wheel: turning the wheel into a page already moving winds the speed up. ↵Adam Soltys3 days1-1/+6
| | | | | | Each notch added the same push whatever the speed, so a sustained turn settled at a stroll (the coast's decay ate what each notch added). Now the push scales with the speed on the page, 1x from rest up to 2.5x at a fast coast, so keeping the wheel turning keeps accelerating; a single notch from rest, the reversal and the trackpad path are as before (feed-momentum + feed-wheel-reverse tests green). Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Feed: a wheel scroll through a loading feed no longer stutters on a laptop. ↵Adam Soltys3 days4-10/+299
| | | | | | The phone was fine (its scroll is the compositor's) but the desktop's wheel coast is driven from the main thread, so every long task froze it, and a feed that was still loading ran dozens of them: each ready post went in through its own admitFeed → holdScroll → full render (200–450 ms apiece; a Popular switch did 49 in a few seconds, one of them 1.3 s). Inside those renders the window's list getter — run on every scroll frame too — filtered every note through the mute/spam/report checks, and each check re-derived the login identity (two parses of the stored state plus a bech32 encode): 11 s of a 2-minute profile. Now ready posts are admitted together, one render per 120 ms, and while the reader is moving the page the batch waits for a quiet moment (up to 1.5 s); nostrLoginIdentity keeps its answer for 250 ms (dropped by its own saves, a signer change or another wallet's key), npubOf is memoized, modOn reads the cached messages state, the foreign-language guess is remembered per post, and the window list is reused between renders. Measured on Adam's dev tab (tools/feed-scroll-jank.js: real CDP wheel notches, chip clicks mid-load, rAF frame gaps, long tasks, a CPU profile that names each busy stretch): stalled time during a switch 1.9–2.5 s → 0–83 ms, no frame over 50 ms on the wheel path, main thread busy 33% → 17%; settled scrolling was already clean. feed-streaming's "tap reveals the oldest ready new post" fails on the previous commit too. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Messages: attach and GIF inside the field, as in the post composer. The DM ↵Adam Soltys3 days2-35/+45
| | | | | | | and room composer still had them as buttons in its row — Adam's long DM to yorgos sat squeezed between a clip, GIF and Send. They now sit at the right of the field's last line (the field keeps room on that side, less with GIF alone), leaving the row as field + Send. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Lock with fingerprint or face. The padlock's "Protect this device?" now ↵Adam Soltys4 days6-19/+353
| | | | | | | offers a passkey instead of a password, and Settings → Security can turn it on or off. The saved-wallets vault is sealed under a secret only that passkey's PRF produces (behind the phone's fingerprint/face check, the passkey kept by the platform's secure hardware or password manager), so nothing in this browser's storage opens it. Opening the app on a locked device asks for the passkey by itself; a cancel leaves an Unlock button. The prompts that need the vault opened (save, forget, lock a stray wallet) take the passkey too; there's no Change password for a vault that has none; the recovery words remain the way back. The lock passkey has its own PRF salt and a marked user handle, not discoverable where the platform allows — passkey sign-in refuses it rather than derive a stranger's key from it. English + Italian strings. tools/passkey-lock-test.js (Chrome's virtual authenticator with PRF). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Notifications: a custom-emoji reaction shows its picture. djmeistro's ↵Adam Soltys4 days2-6/+118
| | | | | | | :peepoClap: read as the bare text "reacted :peepoClap:" — the notification row kept the reaction's content but not its NIP-30 emoji tag (and cut shortcodes at 12 characters). Rows now keep the tag's picture and the whole :name:, draw it like the reaction chips do, and a row saved the old way gets its picture when the relay hands the reaction again. tools/notif-custom-emoji-test.js. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Feed: a mention opens the person, not the post. A tap on @name in a feed ↵Adam Soltys4 days2-2/+62
| | | | | | | post opened their profile and then reached the row, which opened the post's thread on top — Adam had to tap the mention again from inside the thread. The mention stops its tap, and a post's row no longer opens its thread for a tap on any link in it (a mention, a URL, a #tag). tools/feed-mention-tap-test.js (on the old code the tap landed on /nevent1…). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Italiano: the locale itself and the language list (the previous commit ↵Adam Soltys4 days3-2/+1450
| | | | | | | carried only the test's rename — see 3032399 for the description). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Italiano. coinos speaks Italian: all 1,445 strings (the fullest of the ↵Adam Soltys4 days1-14/+14
| | | | | | | locales — the others carry 923 or fewer and fall back to English), placeholders checked, informal "tu", wallet = portafoglio. An Italian phone now opens coinos in Italian, and posts translate into Italian because that's the app's language. The "phone language" translation fallback reads the app's own LANGS (no second list), and its test moved to a Dutch phone: tools/translate-phone-lang-test.js. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Translation: into the phone's language, and DeepL with your own key. ↵Adam Soltys4 days7-16/+261
| | | | | | | isolabellart saw the Translate button but nothing became Italian: the target was the app's language, coinos has no Italian UI (so it ran in English), and the language guess didn't know Italian. Posts now translate into the reader's choice (Settings → Nostr → Translation, any of 31 languages), else the phone's language when the app has no UI for it, else the app's; the guess knows Italian, Dutch and Polish. And, as Jumble offers: a DeepL API key of your own, kept on the device and sent per request as x-deepl-key — the registrar passes it to DeepL (api-free for ":fx" keys; DeepL refuses browsers) and never stores or logs it; a refused key or used-up quota says so once. names: translateDeepl (prose only, links/tags kept, detected source back, 401/402/422 statuses), CORS allows the header. Deployed to cs names. tools/translate-italian-test.js, tools/deepl-translate.test.js. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Messages: the Amber notice no longer waits for Amber to wake. "Messages sent ↵Adam Soltys4 days3-8/+19
| | | | | | | to you can't be opened here · Connect Amber" showed only once the Amber signer had resumed, and the DM drain is what resumes it — on a phone whose every wrap the old code had already logged as opened, nothing is pending, so nothing might ever ask. nostrLoginIdentity now says how the account came in (`via`), and an Amber account counts as one whether or not its signer is live yet (the notice, and the flag that re-opens written-off wraps once a decrypting signer takes over). tools/amber-dm-upgrade-test.js runs that case with ALL_LOGGED=1. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Composers: attach and GIF sit in the field's corner. The post composer's row ↵Adam Soltys4 days4-14/+30
| | | | | | | had grown to Post · 📎 · GIF · Preview · Cancel; the reply box's likewise. The paperclip and GIF button are now small borderless tools inside the text field, bottom right (the field keeps room under its text so nothing runs beneath them), and the row under it is only what you do with the post: Post, Preview, Cancel (Preview, Cancel, Reply in a thread). Tests find the tools in .compose-box. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* GIFs in replies. The thread's reply box gets the same GIF button as the post ↵Adam Soltys4 days2-1/+27
| | | | | | | composer: a pick lands in the draft (preview opened) and the reply goes out with the GIF's NIP-92 imeta. tools/gif-picker-test.js covers it. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Feed: your own posts are in Following. The follows feed read only the people ↵Adam Soltys5 days3-6/+20
| | | | | | | you follow, so your own post was there just after you sent it (the optimistic row) and gone after a reload — Adam didn't see his latest. Like every other client, a follows feed now includes you. Someone who follows nobody still gets the "find people" invitation, with their own posts under it. feed-live-test's catch-up queue now goes only to an ask naming the post's author (the feed also asks for yours); tools/feed-repost-test.js checks your own post shows. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Feed: reposts. Amethyst showed Zapstore's repost of LWB's "Tankful" post in ↵Adam Soltys5 days4-9/+134
| | | | | | | Adam's follows; we showed nothing — a feed asked only for posts (kinds 1, 1068). A feed of people now asks for their reposts too (NIP-18 kind 6; topic and firehose asks stay posts-only), and a repost shows as the note itself, under "🔁 <name> reposted" — its reactions, zaps, ⋯ and thread are the note's. Only reposts that carry the note and whose copy checks out (kind 1/1068, the id the e tag names, a valid signature); a muted author's note stays out. A note shows once: as itself, or under the first repost of it to arrive, counting rows still warming at the door. Media grids skip reposts. tools/feed-repost-test.js Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* GIF search, from nostr.build's GIF index (gifs.nostr.build). A GIF button in ↵Adam Soltys5 days5-8/+339
| | | | | | | the post composer and in every chat composer (DMs, groups, communities) opens a picker: "GIFs from nostr.build" (linked, as their terms require), starter chips (gm, lol, yes, wow, …; the first runs on open), a search field (debounced 250 ms, no one-letter searches, one shared request per normalised query, never aborted, 429/403/503 answered in words), and a masonry grid whose tiles play the small H.264 copy only while near the screen, over the lqip blur, falling back to the animated preview (stills under reduced motion). A pick puts the original URL in a post — published with a NIP-92 imeta (m, dim, size, alt) — or, in a chat, sends it at once after whatever was typed. Media URLs not on gifs.nostr.build are dropped. Browser calls carry no headers: production needs v3.coinos.io registered in the nostr.build dashboard (localhost works as their shared dev client). tools/gif-picker-test.js runs against the live API. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3