aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
Commit message (Collapse)AuthorAgeFilesLines
...
* See the post before you post it.Adam Soltys2026-09-131-5/+46
| | | | | | | | | | | | | | | | | | | | | | | | A draft is written as plain text but doesn't arrive as plain text: a URL ending in .jpg becomes a picture, a youtu.be link becomes a player, a nostr: reference becomes the note it points at, an npub becomes a name. The only way to find out how yours would land was to send it. The eye beside the paperclip — on the composer and on a reply — opens a preview. It runs the draft through noteBody, the very renderer the feed uses, so this isn't a lookalike of the real thing: it IS the real thing, in a dashed frame with a PREVIEW label so it can never be mistaken for the post itself. It follows the draft live while it's open (and only while it's open — a render per keystroke is not free). Attaching a picture opens it by itself. Showing you what you attached is the answer to the question attaching it raises, and it costs no tap. tools/compose-preview-test.js covers both the composer and a reply: the picture renders rather than sitting there as a URL, the YouTube link becomes a player, the preview tracks typing, and it puts itself away. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A profile is already there when you land on it.Adam Soltys2026-09-131-10/+62
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Tapping a face opened an empty page that grew a banner, then a bio, then a column of posts as relays answered — measured as the browser measures it, a cumulative layout shift of 0.412, where 0.25 is already "poor". None of those fetches needed to happen on the tap: The bio and banner were free all along. The batched kind-0 fetch that names everyone in the feed downloads their WHOLE profile and then throws away everything but the name, picture, nip05 and lud16. It keeps `about` and `banner` now, and the page reads them until the full kind 0 lands — after which `full` is the truth, including when it says there is no bio. The posts were in memory. The feed you just tapped through holds up to two hundred notes and an open thread holds its replies; the ones by this author paint immediately while the relay fetch gets their real page. And the rest is a head start: an avatar (or the name beside it) begins its profile's fetches on pointerdown rather than click. That is a hundred odd milliseconds free, and unlike the prefetch-every-row this replaces — sixty requests for twenty rows, which is what starved the profile batch — it only ever asks about the person actually being tapped. Measured after: layout shift 0.000, bio in the first frame, posts in the first frame. tools/profile-open-test.js asserts exactly that, and fails on the old code with 0.412 and a blank page, so it cannot quietly rot. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Bigger faces in the feed.Adam Soltys2026-09-131-9/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Measured on a Pixel 9: Amethyst gives a feed avatar 55dp, we gave it 30 — the same circle the inbox and chat rows use, where a face is a label on a line. In a feed the face is the point. Feed rows now get 44px; everywhere else is untouched. The catch, and the reason this isn't a one-line change: the cached thumbnail was 96px square, sized for a 30px circle at 3x. A 44px circle no longer fits inside it, and the fallback is to layer the ORIGINAL over the thumbnail — which for a feed of twenty rows means twenty full-size downloads, one of which in this wallet's own cache was a 5MB JPEG. That is exactly the slow-avatar problem we already fixed once. So the thumbnail grows with the circle: 144px, which covers 44 at 3x with room to spare. Faces cached at the old size are remade once (thumbPx says which size a thumbnail was made for), and the old one keeps painting meanwhile, so nobody watches a grey circle while it happens. The per-face cap goes 9000 → 16000 chars and the shared budget 180k → 280k, both scaled to the new size: the busiest picture on hand measures 12827 chars at 144px, and at the old cap it would have been rejected and left fetching its original forever. avatar-thumb-test gains the guard that would have caught all of this: the thumbnail must cover the biggest circle in the app at 3x. It failed at 128px (132 needed) — which is why THUMB_PX is 144 and not 128. Its redirect assertion was also order-dependent — the seed writes the profile into every wallet's cache but only the painted one makes a thumbnail, so it asked the first row and got a coin toss. It takes the best copy now. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Posts and messages get a reading size.Adam Soltys2026-09-131-3/+3
| | | | | | | | | | | | | | | | | | | | | | | | | | | | A feed of posts was set in .small — 13px, the size used for captions and row subtitles. On a phone it read like footnotes. Measured against Amethyst on the same Pixel 9, side by side: its note body renders a line pitch of 62 device pixels, ours 50. Post bodies get their own class at 16px, which is Amethyst's own default for a note body, and chat and DM bubbles move 15 → 16 to match; the names and inbox previews above them keep pace. New line pitch measured on the phone: 60 against Amethyst's 62. A quoted note inside a post stays a step smaller, since it's context rather than the post you're reading. Worth recording for whoever reads this next: this phone has the Android font scale at 1.15. Amethyst uses sp and honours that automatically; CSS px in a TWA does not, and there's no way for a page to read that setting. So Amethyst will still render about 15% larger than us on this device no matter what we pick. Matching its DEFAULT is the target that makes sense for everyone else. chat-layout-test had rotted (onboarding copy, .header-msgs now matching the settings button, a screenshot path from another machine's scratch dir) — repaired, and it's what proves the composer still fits at the larger size, on all three viewports. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Feed rows wore punks and npubs because we asked for profiles one at a time.Adam Soltys2026-09-121-23/+104
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | A screenful of eighty rows sent eighty kind-0 REQs in the same breath. Relays cap how many subscriptions a connection may have open, most were refused, the five-second timeout expired, and the empty answer was then believed for ten minutes — which is a real account dressed as a punk with a shortened npub for the rest of the session. Measured against this wallet's own feed, 131 of the 133 people on screen had a kind 0 sitting on relays we were already connected to. It was never reach; it was us. So profiles go out in batches: a 250ms beat collects a paint's worth of rows into one request of up to 200 authors, and each batch paints as soon as it lands rather than waiting behind the outbox fallback chasing stragglers. A miss now backs off instead of sticking — fifteen seconds, then half a minute, up to the ten minutes that's right for someone who genuinely has no profile. And noteRow no longer calls prefetchProfilePage. That fetches an author's whole page — kind 0, relay list, last thirty notes — which for twenty rows is sixty requests, and is what starved the batch in the first place. A row is not a profile tap; the page still loads when the profile is opened. profile-feed-test had rotted in three places (onboarding copy, and .header-msgs now matching the settings button first) — repaired, not rewritten. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed was showing half the posts it should.Adam Soltys2026-09-121-9/+44
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Compared against a wider net over the same six hours and the same follow list — 862 people, read off this wallet: before: 165 notes from 38 people after: 327 notes from 86 people, and nothing the wider net still finds Four things were losing posts, in order of how much: THE AUTHOR CAP. feedAuthors() stopped at 500. On an 862-follow list that silently dropped 362 people, and 164 of the 166 posts the wider net found were theirs. Gone: it takes everyone now, up to a couple of thousand, with each REQ carrying at most 400 authors so no single filter is absurd. THE DEFAULTS. relay.damus.io is the second-biggest relay this follow list writes to — 249 of them — and it wasn't in our set. 232 follows publish no relay list at all and lean entirely on those defaults, so they were being asked for on two relays instead of three. THE SOCKET CAP. Ten relays covered all but 66 follows; twenty-five covered all but 26. It's 24 now — Amethyst runs near a hundred, and this is the same idea kept to a number a phone can hold. MALFORMED RELAY LISTS. One list in these follows crams three URLs into a single r tag, space-separated. It passed our "starts with wss://" check, reached the pool, threw Invalid URL — and every author covered by that relay quietly returned nothing. Each URL has to parse now. And a quote-post is no longer mistaken for a reply. NIP-10 marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and NIP-18 quotes carry a q tag. We were hiding every note with an e tag, which included the quote-posts that now render as cards. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A quoted note shows up as the note, not as "view note".Adam Soltys2026-09-121-8/+58
| | | | | | | | | | | | | | | | | | | | | | A nostr:note1/nevent1 in someone's post IS a post, and making the reader tap a link to see it — losing their place in the feed — was the wrong shape. It renders inside the post now: whose it is, when, and what it said, in a card of its own so the two voices don't run together. Tapping it opens the note properly. Deliberately not a noteRow: a quote is context, not a second post to act on, so it carries no reply, boost or zap of its own. The action row stops at the post that did the quoting. Fetched once per id and remembered, so a feed quoting the same note ten times asks for it once. One level deep only — a quote of a quote of a quote is a rabbit hole, and the inner one stays a link you can follow. tools/quote-card-test.js quotes a real note off the relays and checks it arrives with its author, its text, and none of the outer post's buttons. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A YouTube link in a post shows the video.Adam Soltys2026-09-121-0/+50
| | | | | | | | | | | | | | | | All three shapes it comes in — youtube.com/watch?v=, youtu.be/, and a Shorts link — become the video's still with a play button on it and a corner link out to YouTube. A ?t= keeps its start time. Anything that merely looks like one (example.com/watch?v=…) stays a link. The player itself arrives on the first tap, not on render. A feed of ten videos would otherwise load ten YouTube players, each one telling Google what you're scrolling past before you've decided to watch anything — and on a phone, ten players is the scroll. One tap and it plays in place, from youtube-nocookie.com. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* End to end over the real network, and the advice now fits the app it's in.Adam Soltys2026-09-121-2/+24
| | | | | | | | | | | | | | | | | | | | | | | | | | | A push sent to ntfy.sh reached a killed app on a real phone: 17:02:21.259 ntfy.sh → the phone's ntfy → broadcast to io.coinos.webview 17:02:21.308 app process started (it had been killed) 17:02:21.458 wake: job started 17:02:22.926 wake: finishing (page said so) — 1.7s, and "You were paid" One thing that took two goes: `adb shell am force-stop` marks a package STOPPED, and Android delivers no broadcasts to a stopped package unless the sender sets FLAG_INCLUDE_STOPPED_PACKAGES — which `am broadcast` does and a real distributor rightly doesn't. The first attempt looked like a failure and was a test artefact; `am kill`, which is what Android itself does to a backgrounded app, delivers fine. And the WebView build can now say something useful about push. It was inheriting the browser's line — "your browser has no push service, try Firefox" — which is nonsense inside a WebView that IS the app. It asks the host what's actually there and says: install a push app like ntfy (nothing installed), or wait a moment and reopen (a distributor that hasn't answered yet). Payments and messages still arrive whenever coinos is open, which is the part that matters most to someone reading an apology. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A reply can carry a picture, and the WebView build has a name you can say.Adam Soltys2026-09-121-6/+33
| | | | | | | | | | | | The reply box gets the same paperclip the post composer has: one uploader now, handing the URL to whichever draft asked for it, and the reply's own imeta tag describing what it carries. And the UnifiedPush build is graphene.apk — what people will actually call it, and short enough to read down a phone line. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* UnifiedPush: reach a phone that has no Google in it.Adam Soltys2026-09-121-0/+30
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Web push on Android is Firebase's, so a phone without Google Play Services — GrapheneOS, CalyxOS, any de-Googled Android — cannot be woken by a web app at all. UnifiedPush is the way that doesn't involve Google: the user runs a distributor (ntfy and friends), apps ask it for an endpoint, and whoever wants to reach the device POSTs to that endpoint. THE WRAPPER speaks the protocol directly — four broadcasts each way, so it pulls in no library; an APK this small has no business growing a Kotlin runtime for eight intents. It asks the distributor on every launch (the call is idempotent, and it's how a distributor installed later gets noticed), raises a notification when a message arrives, and acknowledges it so the distributor stops resending. THE ENDPOINT reaches the web app on the launch URL as ?up=, the same road payment intents already travel — because the distributor talks to the app and only the web app knows which pubkeys are worth watching. It's registered with the notifier in place of a browser subscription, for notifications and for NWC alike, and needs neither permission nor push service. THE NOTIFIER sends to either kind: one deliver() that POSTs the payload to a UnifiedPush endpoint, or hands it to web-push as before. Same payloads, same 404/410 pruning. The endpoint is the only secret there, so it's never logged in full. Verified: a POST to a live ntfy endpoint delivers the payload byte for byte, so the app parses exactly what the notifier sent; the APK builds, carries the receiver, and is signed with the certificate assetlinks already pins (v1.6). tools/unifiedpush-test.js drives a browser with no push service and checks the endpoint is taken off the URL, kept, and registered. Honest about the limit: a UnifiedPush message wakes the ANDROID app, not Chrome's service worker, and the wallet's keys live in the browser's storage where nothing here can reach them. So a zap request arrives as a notification you tap, and the wallet answers when it opens — one tap instead of none. Automatic answering while closed still needs a browser with a push service. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Tell a phone with no push service what's actually wrong.Adam Soltys2026-09-121-1/+16
| | | | | | | | | | | | | | | | | | | | | | | | | "Registration failed - push service error" is Chrome's own wording, and it tells nobody anything. The cause is that Chromium routes web push through Firebase: on GrapheneOS — or any Android without Google Play Services — there is no push service to register with, so the app cannot be woken while it's closed, and no amount of tapping the toggle will change that. Both places that ask for push (the chat's offer and the NWC "answer while closed" toggle) now recognise that specific failure and say: this browser has no push service; on Android that's usually a phone without Play Services; Firefox brings its own and works there; and zaps are still answered whenever coinos is open. The last part matters most — the feature isn't broken, it's narrower than they thought. The failure is remembered so the app stops retrying it on every launch, and an explicit tap on the toggle always clears that and tries again, because a phone that changed browsers deserves another go. tools/push-unavailable-test.js runs a browser whose subscribe() fails exactly as Chrome's does and reads back what the user is told. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Every answer to a post lives in one row beneath it.Adam Soltys2026-09-121-36/+74
| | | | | | | | | | | | | | | | | | | Reply and zap have come down from the header to join boost, quote and the heart, spread across the post's full width so each is a comfortable target instead of five buttons huddled in a corner. Only the ellipsis stays up top. Proper icons at a readable size, all the same weight: a speech bubble for reply (a return arrow is what a keyboard does, not what a person does), the repeat arrows for boost, quote marks, a heart, a bolt. Counts sit beside the icon they count. And the heart asks which feeling: tapping it opens the same six emoji the chat sheet offers, and the one you pick is the one the post then shows — NIP-25 takes any content, so there was never a reason to assume a heart. Tapping your own reaction still takes it back. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Answer a post: like it, boost it, quote it, or stop seeing its author.Adam Soltys2026-09-121-13/+271
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Under every post now: a heart with its count, a boost with its count, and a quote. Behind an ellipsis beside reply and zap: quote, boost, copy the note's reference, mute the author, or block them. Likes and boosts come back in the SAME relay request the zap tallies already make — one round trip answers all three — and each action paints before any relay does, the way the zap chip learned to. A like is kind 7 and a boost kind 6, published to the author's own write relays as well as ours, so the person being answered actually sees it. A quote opens the composer with a nostr:nevent reference already in the text and tags the post on publish, so other clients render the embed. Taking a like back is the only withdrawal nostr has — a deletion request naming the reaction. Mute is the NIP-51 list (kind 10000): their posts leave the feed at once and the list is published, so it follows you to other clients. Block is the same list plus an unfollow, which is why it says so. And a bug the mute found, which the follow list had too: the optimistic paint was being read back as fact. Both toggles paint the answer immediately and then publish onto the newest list the relays will give them — but when the relays had nothing newer, "the newest list" WAS the optimistic paint, so the code saw the follow already present and published a list without it. Every follow was a no-op that looked like it worked. The base is now the relays' copy only when it's genuinely newer, and otherwise what we held before the paint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A picture in a post opens full screen, and the paperclip is a button again.Adam Soltys2026-09-121-4/+9
| | | | | | | | | | | | | | | | | | | | The attach control was a .btn-sm holding a 16px icon, wedged between two full-height buttons — a sliver to aim at on a phone. It's a 48px square the height of the row now, with a 20px clip in it. And tapping an image opens it full screen: the image centred on a dark ground, tap anywhere (or Escape) to close. Posts crop images to 320px so a feed stays a feed, which makes a photograph a thumbnail — this is where you actually look at one. Two details in the viewer. The page underneath stops scrolling while it's open, and the scrollbar gutter the page keeps reserved stands down with it: fixed positioning is sized to the viewport WITHOUT that gutter, so an "inset: 0" backdrop leaves a pale stripe down the side of an otherwise full-screen picture. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed opens at twenty posts and runs to the page's edge.Adam Soltys2026-09-111-3/+16
| | | | | | | | | | | | | | | | It was rendering everything it held — a hundred-odd notes to show the few you read. It now paints twenty, and reaching the bottom shows twenty more; only when the window catches up with what's cached does it go back to the relays for older posts. Posts arriving live at the top widen the window rather than pushing off the one you'd scrolled to. And the posts are the page now, not a card sitting inside one: they step out through the chat page's padding to the container's own edge, leaving one continuous list with hairlines between the posts instead of two sets of card walls. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed reads each author on the relays they actually publish to.Adam Soltys2026-09-111-19/+137
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Our set is three relays — relay.coinos.io, nos.lol, relay.primal.net — plus whatever the user's own NIP-65 names. Asking those for everyone's posts only finds the people who happen to use them; anyone else looks like they stopped posting. So the feed now reads NIP-65: relay lists are fetched in batches of a hundred, kept for a week, and the authors covered greedily — the relay carrying the most people not yet covered, repeated up to ten sockets. A feed of hundreds is a handful of connections that between them reach everyone, rather than one each. Our relays are still asked about everyone, not just the people with no list. Measured across eight busy accounts, each source holds posts the other misses: reading the author's own relays surfaced 7 to 40 notes ours had never seen (jb55 +40, verbiricha +25, pablo +23), and ours held plenty theirs didn't — fiatjaf's own three relays answered 42 where ours answered 67. The feed wants the union, so the outbox adds reach rather than replacing it. On the test set that took the feed from 83 posts to 118. Profile pages get it for free: relaysOf now reads the batch's cache first, so opening someone you follow costs no extra round trip. The test records every socket the page opens and checks the plan by what it actually dialled. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Follow people, read a feed of them, and put pictures in a post.Adam Soltys2026-09-111-30/+306
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | FOLLOWING. A profile page gains Follow / Following beside DM and Pay. The contact list is the kind-3 event, kept locally so the button is right in the first frame and refreshed from relays behind it. Publishing one is delicate: a contact list is a shared document — other clients keep relay hints in its content and petnames in its tags — so a follow re-fetches the newest list first and adds or removes exactly one p tag, preserving everything else. The button flips immediately and flips back if the publish fails. The service worker's friend filter reads the same list, so following someone also means their DMs stop being treated as a stranger's. THE FEED. Posts by the people you follow, newest first, at the top of the messages home. Replies are left out: a reply belongs to its thread, and a timeline of half-conversations reads like eavesdropping. The last screenful is kept locally so it opens with posts in it rather than a spinner, new posts arrive over a live subscription while you're looking at it, and older ones stream in as you reach the bottom — the same shape as the profile pages. A wallet that follows nobody is told so and offered the people search. MEDIA. The composer (now shared by the feed and the profile page) takes a picture or a video: it uploads when you pick it and appends the URL to the text, which is how every nostr client reads media, with a NIP-92 imeta tag published beside it for the ones that would rather be told than guess. Posts already render images and video inline, so they show up where they land. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Restore profile editor drafts across refreshesAdam Soltys2026-09-111-6/+12
|
* Offer migration only for submitted unmigrated legacy namesAdam Soltys2026-09-111-4/+10
|
* Retry avatar thumbnail failures from the old download methodAdam Soltys2026-09-101-3/+13
|
* The thumbnail fetches the bytes instead of trusting a crossOrigin <img>.Adam Soltys2026-09-101-24/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | Pinguimdarelva's avatar still flashed, and the reason was one I'd have kept guessing at: from the live browser, `new Image()` with crossOrigin set FAILS for that picture, while `fetch(url, {mode:'cors', cache:'reload'})` for the very same URL returns 200. The host allows us perfectly well. The picture sits behind a 302, and the avatar is painted as an ordinary background-image — so the bytes are already in the HTTP cache under the redirect's target as a no-CORS entry, and the crossOrigin <img> reuses that entry and fails the CORS check. The image loads, the CORS copy "errors", and the face gets written off. So the thumbnailer fetches the bytes itself and draws from a blob: no crossOrigin element, no cache collision, and an untainted canvas without depending on the element's CORS bookkeeping. With no taint left to detect, failures no longer split into hard and soft — each one just backs off, from six hours up to a fortnight, so a host that truly won't have us is asked twice a month instead of every boot. The test now serves a picture behind a redirect and loads it plainly before thumbnailing it, which is the shape that broke; it fails on the old code. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A transient failure shouldn't cost a face its thumbnail for good.Adam Soltys2026-09-101-11/+84
| | | | | | | | | | | | | | | | | | | | | | | | | Pinguimdarelva's picture is 52KB on a CDN that answers in 700ms one minute and times out the next — I measured both from this machine. The thumbnail attempt hit one of the bad minutes, and `thumbFail` was written as if the host had refused us: never retried, so that avatar went back to fetching the original on every boot. Only a tainted canvas is a real refusal (no CORS header, nothing to be done), and that is remembered for good. Everything else — a load error, or a host that won't answer inside twenty seconds — is stamped with the time and tried again in six hours. The timeout also frees the slot: three thumbnails run at a time, and a hanging image used to hold one of them indefinitely. And zap tallies are remembered between sessions. They were asked of the relays on every boot, which takes seconds, so a room full of zapped messages painted bare and filled in afterwards. The totals (never the receipts) are kept locally and painted straight away; the moment the relays answer for a message, their word replaces it — so a tally that changed, or a receipt that was deleted, still settles correctly. Opening a DM thread doesn't forget what the chat had learned, and the store is capped at 400. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A face we've never seen is drawn from the pubkey, not left blank.Adam Soltys2026-09-101-6/+18
| | | | | | | | | | | | | | | | | | | | | | | The avatar delay left on the messages page wasn't the image at all. A person with no cached profile got an empty circle until a relay answered — and the one that stayed blank longest (npub1k6f89cg, in the live cache) is never cached at all: persistProfile deliberately refuses to write a row with no name and no picture, so every boot re-asked the relays and every boot sat blank for seconds. When it finally arrived it was a punk, which is why it looked like slow art rather than a slow lookup. The punk is derived from the pubkey alone, so it can be drawn in the first frame. It's already the right answer for everyone with no picture, and for a coinos user who kept the default it IS their published picture — same formula, same file. A real photo replaces it when it lands. A name lookup in flight still gets the quiet circle: a picture is expected there, and punk art must not flash in front of it. Also: the punk rewrite now recognises the bare relative path a few older profiles carry, not just the absolute URL. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Ship the punks at the size we actually draw them.Adam Soltys2026-09-101-6/+28
| | | | | | | | | | | | | | | | | | | | The art is 240x240 (20KB each, 1.5MB for the set) because that's what a punk published as someone's nostr picture should be — but the circles the app draws are 18 to 64px, so a new wallet with nothing cached was fetching eight times the pixels it could show, one request per face. punks-sm carries a 128px copy of each: 4KB instead of 20KB, indistinguishable at 64px. Small circles and the wizard's picker tiles load those; only the 64px profile avatar and the value the wizard publishes stay full-size. The bigger win is that a punk picture is our own art wherever it turns up. Everyone who keeps the default publishes v3.coinos.io/punks/N.webp as their nostr picture, so those faces now paint straight off disk at the size being drawn — no request, no thumbnail to make, and no broken circle when the legacy coinos.io copies 502 (which they do for a third of the set). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Keep a thumbnail of every face, so they're on screen in the first frame.Adam Soltys2026-09-101-3/+94
| | | | | | | | | | | | | | | | | | | | | | | | | | | A profile picture is whatever its owner uploaded, and that is very often the full-size original. Measured against the faces this wallet had cached: one avatar is a 4,988,208-byte JPEG, another 3,901,966 — megabytes fetched and decoded to paint a 30px circle. That is the second of blank circles on the messages page after a refresh, and it repeats every time the upstream cache header expires. So the first time we see a picture we downscale it once (96px, webp) and keep the thumbnail beside the profile in localStorage, which is read synchronously at boot. Small avatars then paint from it alone and never ask the network for the original again; the 64px profile avatar layers them, so the thumbnail shows instantly while the full-size original loads over the top. Reading the pixels needs CORS — most avatar hosts allow it (6 of the 9 here), and the ones that don't are remembered as failures rather than retried every boot. A refresh of the profile carries the thumbnail across when the picture hasn't changed. Thumbnails live on a 180k budget, oldest face first, and Data Saver opts out of making them at all. tools/avatar-thumb-test.js watches a wallet meet a large cross-origin picture and checks the next boot fetches no picture at all. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A zap of 21 showed 42 the moment it landed.Adam Soltys2026-09-101-12/+38
| | | | | | | | | | | | | | | | | The optimistic amount is supposed to stand down when the real receipt is counted, and it did — but only by deleting itself. An ark zap publishes its own receipt, which comes back over the live subscription BEFORE the send call resolves, so the flow's success report arrived second and re-created the amount on top of the receipt already in the total. 21 paid, 42 shown. The pending entry now has a state (flying / paid / void) instead of a deletion: our receipt voids it, and a settle arriving afterwards can't resurrect it. A receipt older than the tap — the backlog the first paint fetches for a message zapped before — doesn't void anything, so zapping the same message twice still counts both. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The zap chip lands on the tap and pulses until the payment settles.Adam Soltys2026-09-101-8/+57
| | | | | | | | | | | | | | | | | | | A zap took a few seconds to pay and its public receipt trailed that by seconds more, so the tally chip appeared long after the tap — the tap looked ignored, and the only way to tell it had worked was the toast. Now the chip appears on the tap carrying your amount, breathing while the payment is in flight; it goes solid the moment the payment lands, and steps aside when the real receipt arrives (which is also how it avoids counting your zap twice). The ark and Lightning one-tap flows report their outcome back — a failure, or a fallback into the form, takes the chip straight off again rather than leaving it pulsing. An unreported zap gives up after 45s. A zap sent from the form reports too, so the tally shows it right away instead of waiting on a relay. Reduced-motion gets a dimmed chip, no pulse. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Zaps in chat, and a bolt + sats tally on chat messages, DMs and posts.Adam Soltys2026-09-101-8/+121
| | | | | | | | | | | | | | | | | | | | | Chat and DM message sheets grow a ⚡ Zap item (not on your own messages), riding the same one-tap flow as a post zap — Ark first, Lightning fallback, the amount remembered from the first time. A chat message is a rumor with an id like any event, so the zap e-tags that id; the public receipt reveals nothing but the id. Every post row, chat bubble and DM shows how much it's been zapped as a little amber bolt + sats chip (1.2k / 21k / 1.5M), fed by the public receipts that e-tag it: NIP-57 kind 9735 (amount from the bolt11, the zap request's amount as fallback) and coinos' own Ark receipt, kind 9737 (network-checked). Summed per id, deduped by receipt; underlined when one of ours is in it. Ids are asked once each (batched per paint, 150 per REQ), and ONE live subscription follows the ~200 ids most recently on screen so a zap landing while you watch — yours included, rechecked at 4/12/30s — shows up without a reload. The tally chip is itself a zap button on others' messages. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Identity switching moves to the profile page, with faces.Adam Soltys2026-09-101-2/+37
| | | | | | | | | | | | | | | | The Accounts screen is about a seed's Spending/Savings faces — its rows say nothing about WHO each pair belongs to, so a "Sign into another account" button there was the wrong door. It's gone from there. On your own profile, "Sign into another account" now opens a switcher: every identity already signed in on this device as an avatar + name row (one per seed, wearing the nostr key its header avatar shows — the login key when there is one, else the seed's own; remembered on the account and in the durable directory by xpub, so vault-restored accounts have a face too), tap to switch with nothing logged out, plus "Sign into a new account" for the additive sign-in doors. With no other identity on the device the button goes straight to those doors. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Profile page gets "Sign into another account" beside Log out.Adam Soltys2026-09-101-0/+9
| | | | | | | | | | | | The additive sign-in door lived only at the foot of the Accounts screen; the profile page — where people actually go looking for a way out of the current identity — offered just Log out. Now it carries the same button, via a shared signInAnother action on the feature ctx (clears the profile/chat state so the unlock screen's Back lands on the wallet, and the new identity doesn't open onto the old identity's profile). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Profile posts keep a draft across reloads, like DMs and channels do.Adam Soltys2026-09-101-5/+12
| | | | | | | | | | | The new-post composer held its text in a transient ui field, so the reload that reconnects a signer (or any reload) lost the post. It now rides the same persisted draft store under a 'post' key: the composer reopens with the text after a reload, posting clears it, Cancel closes the composer and keeps the text for next time. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Profile posts: a missing signer opens the reconnect screen (with the nsec ↵Adam Soltys2026-09-101-8/+15
| | | | | | | | | | | | | | | | field), not a toast. A pasted-nsec login is never stored, so a reload leaves the link without a signer. The post, reply and profile-edit paths checked for an identity by THROWING the "signer isn't connected" message, which the composer then caught and toasted — bypassing noIdToast, whose whole job is to open the reconnect screen where the nsec (or extension / bunker) can be re-entered. Those paths now go through requireIdentity(): it opens the screen (or toasts, when there's no link to reconnect) and throws a silent error the callers don't repeat. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Phone boot: remember opened DM wraps across boots; supersede stuck ↵Adam Soltys2026-09-091-5/+29
| | | | | | | | | | | | | | | | | | | | | relay-only sync records. A remote-signer (bunker) wallet re-opened the relay's last ~400 gift wraps on EVERY boot — the seen-set lived in memory only — and each wrap costs two NIP-46 round trips with a schnorr sign+verify apiece on the client. On the Pixel that was ~4s of a 15s pegged boot. Opened (and given-up) wrap ids are now kept in the messages feature state, bounded to 1500 id prefixes, and loaded before the inbox subscription. The outbox also replaces a record whose route changed even when its digest matches: the 874KB relay-only ark record that could never publish would otherwise have stayed in front of its Blossom successor forever. Verified live: the wallet's 633KB ark domain uploaded to blossom.coinos.io and its pointer was acknowledged by relay.coinos.io on the next boot. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* DMs: one nostr identity per wallet (stop the phantom seed-key inbox).Adam Soltys2026-09-081-2/+20
| | | | | | | | | A wallet has two nostr keys — the seed-derived key and, when signed in, the login identity (extension/bunker/passkey/Google). Sending already used one identity, but RECEIVING/advertising used both: startDMs subscribed kind-1059 gift wraps on '#p' for both keys and fetched both keys' inbox relays, and ensureDmRelayList always published a kind-10050 inbox for the seed key even under a login. Result: a login wallet advertised the seed key as a SECOND DM inbox, so the welcomer greeted both keys (duplicate welcome) and a person's DMs split across two identities — one showed up as if from "another user". Collapse to a single DM identity: new dmSubKeys() returns just the login pubkey when signed in, else the seed key, and startDMs subscribes/fetches only that; ensureDmRelayList no longer advertises the seed key when a login identity is active. isMe() and dmDecryptors() stay tolerant of both keys so already-received messages still thread and align. Not a privacy fix — nothing ever leaked to a third party (both keys were the user's own); this removes the confusing second self-identity going forward. (Existing cached DMs to the seed key on a given device don't retro-clean; a chat re-sync/forget clears them.) Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Chat reactions render inside the bubble (Telegram-style), not floating below it.Adam Soltys2026-09-061-13/+13
| | | | | | | Reactions were a sibling of the bubble (a separate row under it), so they hung outside the message. Moved the reaction pills inside .chat-bubble, tucked under the text, across all three renderers: community channel messages, DMs, and the read-only public /chat page. The pill background and the "your reaction" highlight border now derive from the bubble's own text colour (color-mix on currentColor), so they read correctly on both the plain bubble and the accent "me" bubble. Tapping a pill stops propagation so it toggles the reaction without also opening the message action sheet. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Heal dead push subscriptions from a VAPID key rotation — the reason ↵Adam Soltys2026-09-051-2/+15
| | | | | | | notifications went silent. registerPush reused whatever getSubscription() returned and only ever subscribed fresh when there was none; a subscription made against an OLD VAPID key can never receive a push (the notifier's send fails with a permanent 403, which was never pruned, and the client kept re-registering the same dead endpoint). Now the client fetches the current VAPID key first and, when an existing subscription's applicationServerKey doesn't match, unsubscribes and re-subscribes against the current key — so a device self-heals on next open. And the notifier prunes 403 endpoints alongside 404/410. Deployed nwcpush on cs Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Payment push notifications are scoped to their network, so a mainnet payment ↵Adam Soltys2026-09-051-1/+6
| | | | | | | stops opening the staging PWA. The same seed derives the same nostr pubkey on mainnet and staging, so both PWAs registered push under one pubkey and the notifier — matching only by pubkey — fanned every mainnet payment out to the staging subscription too; tapping it opened staging.coinos.io. Now the client stamps its network on the push registration (pushWatch net: getNetwork()), the registrar declares its own network on the /notify call (CFG.forwarder.network), and nwcpush delivers a payment only to registrations whose net matches — with a rollout-safe fallback: a device that hasn't re-registered yet (net=null) still gets the push, and a caller that omits net still reaches everyone, so nobody goes dark mid-deploy. Deployed names + nwcpush on cs Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Chat goes full-bleed on phones like the notes feed — the home list, ↵Adam Soltys2026-09-051-3/+3
| | | | | | | community rooms and DM threads (now sharing a chat-page class) drop their card walls and run edge to edge under 520px — and the type steps up a notch: bubbles 14→15px, names 13→14px, timestamps 11→12px. Plus: a fixed-price LNURL (minSendable == maxSendable — bolt-card top-ups, paywalls, static charges) no longer prompts for an amount — resolve() fills it and locks the field via the same fixedSat machinery fixed-amount BOLT 12 offers already use (disabled input, adjusted hints, confirm bypass) Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Public kind-1 publishes (profile posts and thread replies) carry a NIP-89 ↵Adam Soltys2026-09-041-1/+8
| | | | | | | client tag — ['client', 'coinos'], name-only since no kind-31990 handler coordinate is published yet — so other clients show "via coinos". Deliberately absent from everything encrypted (DMs, community wraps): private traffic needs no billboard Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Hats no longer pop in a beat after page load. The hats feature is a deferred ↵Adam Soltys2026-09-041-1/+17
| | | | | | | chunk, so every boot painted bare heads until it arrived — but the core bundle can't carry the hat art. Instead, wrapAvatar now leaves a STAMP per head in localStorage (art + percent geometry, size-independent, capped at 40, cleared when a head goes bare so removals can't ghost), and the core avatar factory replays stamps until hatsReady answers — from then on a null from wrapAvatar genuinely means bare. Verified headless: a stamped hat is on the header avatar 15ms after DOM-ready, and once the chunk lands with a bare-head answer the stamp is cleared with no double-wrap Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Three profile/thread refinements from field use. (1) The thread reply box ↵Adam Soltys2026-09-041-62/+114
| | | | | | | sits INLINE, directly under the note it answers, instead of at the bottom of the thread where nobody scrolled to find it — slotted after the focused note (the root by default), appended at the end only until the focused note loads, with a refocus handshake so the cursor survives the box relocating when the thread finishes loading. (2) Posting is OPTIMISTIC: a provisional dimmed copy tops the feed before any signing or relay round-trip (a remote signer alone can take seconds), swapped for the signed event on success and withdrawn on failure — the composer closes immediately and reopens with the text intact if publishing fails; pending rows can't be opened/replied/zapped until real. (3) The full header nav (search, chat, settings, lock, avatar) now shows on the profile, thread, and search screens whenever a wallet is open — losing the search button on a profile made finding the next person a trek back home; the public no-wallet surface keeps the plain brand. All verified headless: box renders mid-card under the focused note with focus landing after load, a post appears 250ms after tapping Post (pre-publish) and survives settle, and the search button works from a profile Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Every nostr post row carries a ↩ Reply button beside the ⚡ zap: it opens ↵Adam Soltys2026-09-041-1/+14
| | | | | | | the post's thread focused on that note and puts the cursor straight in the reply box (inside an already-open thread it just re-targets the reply to that message). Verified headless on /adam: 51 rows all carry the button, tapping one lands with the thread reply input focused Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Profile notes become a real feed: INFINITE SCROLL (nearing the page bottom ↵Adam Soltys2026-09-041-6/+53
| | | | | | | pulls the next page of older kind-1s — until = oldest seen, one fetch in flight, a short answer marks the end; the listener registers at feature construction so the public no-wallet /username pages scroll too), VIDEO rendering in noteBody (mp4/webm/mov/m4v as <video controls preload=metadata playsinline> beside the existing inline images), and FULL-BLEED on phones — the notes card drops its walls and runs edge to edge under 520px; the inner 440px scrollbox is gone everywhere, the page itself scrolls. Verified headless at phone width on /adam: feed spans 0px-to-edge both sides, images render inline, and scrolling grew the list 51 → 82 posts Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* The tap-a-bubble action sheet works in DMs too: reactions + Reply + Copy (no ↵Adam Soltys2026-09-041-6/+126
| | | | | | | Delete — a gift-wrapped DM can't be retracted from the peer's relays). DM reactions ride the kind-7-in-a-gift-wrap shape 0xchat uses (makeDMReaction in dm.js), sent to the peer and echoed to our own inbox so other devices fold them in; inbound kind-7 rumors land in dmReacts keyed by the reacted message's id — no thread lookup, so a reaction arriving before its message still sticks. Replies e-tag the quoted kind-14 (makeDMRumor grew extraTags) and render the same accent-bar quote as community chat; the composer gets the same replying-to bar. Chips are joinable and your own reaction is highlighted/replaceable, all optimistic with rollback. Round-trip verified: reply and reaction rumors wrap/unwrap with tags intact Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* An uploaded (or pasted) profile picture or cover previews at the top of the ↵Adam Soltys2026-09-041-6/+17
| | | | | | | profile page immediately, not after Save: while the editor is open the DRAFT is the truth for the banner, the avatar, and the display name shown up top — clearing a field previews the removal too. The editor's text fields render per keystroke so pasted URLs track live (the morph never rewrites a focused field, so it can't fight the typing); uploads already rendered after filling the field. Verified headless: typing picture/cover URLs into the edit form updates the page-top avatar and banner backgrounds before any save Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Your own profile page opens as a PROFILE now, not a form: npub (tap to copy ↵Adam Soltys2026-09-041-13/+58
| | | | | | | — it was hidden on your own page), bio, and latest posts, exactly like anyone else's, with the editor behind an explicit Edit profile button (Save or the new Cancel returns to the view) and a New post button that unfolds a composer — publishPost signs a plain kind-1 with the login/wallet identity, publishes to the identity's own relays, and prepends it to the notes list so it shows immediately. The header avatar's tooltip says "Your profile" instead of "Edit profile" to match. Compose/edit state clears on open/back so drafts never leak across profiles. Verified headless end-to-end: onboard → avatar → view mode (buttons + npub, no form) → Edit opens form → Cancel → New post opens composer Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Telegram-style message action sheet + replies in community chat. Tapping any ↵Adam Soltys2026-09-041-14/+92
| | | | | | | bubble opens a centered sheet: a quick row of the six reaction emojis (your current one highlighted; picking fires immediately) above Reply / Copy / Delete (delete on own messages only) — replacing the hover-only 🙂 picker, which was invisible on the phones that need it most. Links, images and the hover × keep their own clicks, and a desktop text-selection drag never pops the sheet over the copy. REPLIES are new: Reply arms a composer bar showing who/what you're answering (× to cancel, cleared on channel/room switch), the sent kind-9 e-tags the quoted message, and rows carrying an e-tag render the quoted context above their text — author + one-line snippet, silent when the original is deleted or not held. The public /chat page renders the same quotes. Verified against the built stylesheet: 300px sheet, six even emoji buttons, quote ellipsizes, circular cancel button Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Chat renders links and sends emoji reactions. Links: community bubbles and ↵Adam Soltys2026-09-041-3/+51
| | | | | | | DM bubbles now run through noteBody (URLs → anchors, image URLs inline, npub mentions clickable, all content kept as text nodes — nothing from a relay reaches innerHTML); the public /chat page gets its own linkedBody (same splitter, nostr refs stay dim stubs since there are no profile screens there). Reactions: a 🙂 opener on every bubble (rides the edge like the delete ×, steps left on own rows) unfolds a six-emoji picker; tapping an existing chip joins that vote, your own reaction is highlighted, and picking a different emoji replaces it (kind-7 rumor on the channel stream, one per author, fold keeps the latest — inbound rendering already existed, this adds the sending half). Optimistic like sendMessage: the chip lands instantly and is rolled back to whatever stood before if signing/wrapping fails Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Public read-only coinos community chat at /chat — a share-anywhere link ↵Adam Soltys2026-09-041-15/+7
| | | | | | | that shows the live chat history to anyone, logged in or not, with a "Sign in to chat" button ("Open coinos" when the device already has an account). No server-side decoder needed: the community's join material was already public by construction (baked into the client bundle — every coinos user is meant to be a member), so the page derives the stream keys and decrypts wraps client-side exactly like the app's Chat tab, worker-offloaded with the same sliced fallback. Renders messages + edits + deletes + reactions and honors the folded control state's ban list; presence/typing/guestbook deliberately omitted. Profiles come from ONE batched kind-0 query per burst (a REQ per author tripped relay concurrency limits). Plumbing: the join material moved to src/community.js (shared with messages.js so they can never drift), /chat is excluded from the single-segment profile deep-link (which was eating the path and rewriting the URL before the route ran), and app renders stand down in public-chat mode so a feature-init render can't paint onboarding over the page. Verified headless against the live relays: 59 messages decrypt, ban-fold applies, names resolve Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz