summaryrefslogtreecommitdiffstats
path: root/src/features
Commit message (Collapse)AuthorAgeFilesLines
* A thread reply is a growing textarea: Enter sends, Shift+Enter breaks the line.Adam Soltys2026-09-171-5/+21
| | | | | Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Pay from a profile opens the Send form, not a lingering payment detail.Adam Soltys2026-09-162-3/+3
| | | | | | | | | | | A tapped history row claims the whole tab area whichever tab is picked, so jumping to ui.tab = 'send' from a profile's Pay button (or a zap's form fallback) with a renewal's detail still open showed that detail instead of the form. showSend() closes any open detail first; the three feature paths that jump to Send use it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A note that wasn't found is asked for again, with the reactor's relay hint.Adam Soltys2026-09-161-13/+22
| | | | | | | | | | | | A reaction's target was fetched once from our relays, and a miss was final for the session — so with our own relay down for an evening, a reply that sat on four relays showed as "no longer on your relays" until a reload. A missing note is now retried when next wanted (half a minute on), the reaction's e-tag relay hint rides along, and tapping the row asks again instead of doing nothing. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Feed: what arrives waits behind the pill; the count bumps; no auto-flush.Adam Soltys2026-09-161-13/+26
| | | | | | | | | | | | | | Posts a refresh found slid in a beat after the cached page painted, moving what you had begun reading, and reaching the top of the page let waiting posts in with a re-render under a finger on its way to the pill — the tap landed on the post beneath. Now everything that arrives after the first paint, the boot catch-up included, waits behind the pill wherever you are; the pill is a keyed node whose count bumps on each change; and a tap scrolls to the top before painting so the new posts open under your eyes. A rebuilt follow list still rebuilds the feed in place. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A bare @npub in a message resolves to the person's name.Adam Soltys2026-09-151-4/+8
| | | | | | | | | | Vector writes a mention as @npub1… with no nostr: prefix; ours only resolved the prefixed form, so the raw key sat in the bubble. Bare npubs and nprofiles, with or without the @, now render as @name and open the profile, like the prefixed ones. Keys inside URLs are left alone. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A changed reaction stays changed: the fold keeps the newest, not the last to ↵Adam Soltys2026-09-151-3/+20
| | | | | | | | | | | | | arrive. One reaction per author was folded as "last one in wins". A reload's backfill hands reactions back in relay order, so a flame swapped for confetti came back as the flame. Rooms, DMs and the public page now keep the newest by the rumor's own clock, and an optimistic send stamps its time so an older one arriving afterwards stays out. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Community reactions carry the target author's p tag, as Concord specifies.Adam Soltys2026-09-151-1/+4
| | | | | | | | | A reaction sent from v3 never showed in Vector: its parser requires the NIP-25 p tag naming the reacted-to message's author (CORD examples §2.3 has e, p and k), and ours sent only e and k. Added. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Gift detail links the coin behind the gift, and the claim once claimed.Adam Soltys2026-09-151-2/+17
| | | | | | | | | | | An unclaimed on-chain gift has no transaction of its own yet — its funding transaction is presigned and only broadcast when someone claims. The coin it's locked against is on-chain from the start, so the detail page now links that outpoint's transaction on the block explorer, and the claim transaction once it exists. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Coins page: renewal is automatic, and the copy says so first.Adam Soltys2026-09-151-4/+6
| | | | | | | | | | | | | | The renewal card opened with how to renew and mentioned only at the end that the wallet does it by itself, which read as an invitation to renew by hand. It now leads with the automatic, free renewal before expiry, then says renewing early is only for someone who would rather not count on the coinos server, with the round cadence taken from the server's own info (two minutes now, not the hour the old text claimed). The "past its expiry" footnote under the table is gone. Stale translations of the rewritten strings fall back to English. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Cached faces warm from the wallet's own namespace, once its keys are in.Adam Soltys2026-09-151-0/+11
| | | | | | | | | | | | | On the phone the profile cache was intact — 150 faces, our own with a thumbnail — yet an offline boot drew a punk in the header. The header asks for a face before a silent sign-in has finished resuming, and the warm-up ran then: it read an empty slot under a keyless namespace and counted itself done. Online the relays refilled the map and nobody saw it; offline nothing did. The warm-up now waits for the wallet's keys and runs again at feature init, from the right namespace. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Offline, the chat shows what it read: messages cache on arrival.Adam Soltys2026-09-151-2/+16
| | | | | | | | | | | | | | | | The room cache was written only on our own sends, deletes and attachments, so a member who only reads had nothing on an offline boot — "No messages yet" over a room read the day before, and no faces or names with it. Messages now persist as they arrive (debounced past the backfill burst), so the last fifty per channel are there without a network. Our own face never waits its turn for a thumbnail and never gives it up to the budget, so the header avatar paints offline too. tools/offline-cache-test.js boots the PWA build, caches a room, reloads fully offline and checks messages, names and thumbnails are all there. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Spending coins as a headed table instead of chip rows.Adam Soltys2026-09-151-30/+29
| | | | | | | | | | | Every row repeated "expiry", "exit cost", "renewal cost" as pills. The labels now sit once in a header row over right-aligned tabular figures; the table's wrapper scrolls sideways on a screen too narrow for it (the page never does), and it fits a 390px phone without scrolling. A tap anywhere on a row toggles it. A sub-satoshi renewal share reads "< 1 sat". Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A tiny coin's renewal chip says "under 1 sat", not "1 sat".Adam Soltys2026-09-151-2/+10
| | | | | | | | | | | | | The server prices a renewal on all the coins together: ppm accumulates sub-satoshi across every input and rounds up once. The per-coin chip rounded each coin's share up on its own, so nine 1-sat coins each showed "renewal cost 1 sat" under a total of 4 sats — as if the page were skipping them. The chip now shows the coin's real share: "under 1 sat" for a sub-satoshi one, rounded otherwise, and its tooltip says how the fee is priced. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Chat keeps its relay subscriptions alive while the app is on screen.Adam Soltys2026-09-151-16/+75
| | | | | | | | | | | | | | | | | | | | | New messages rang in Vector but sat unseen in v3 until a reload. The resume hook only rebuilds subscriptions after an absence it was told about; a socket that errors while the page stays visible (a wifi hop, a laptop lid, a phone freezing the tab without the away timer noticing, another tab clearing that timer first) is dropped from the nostr-tools pool with every subscription it carried, and nothing dials it again. A watchdog now runs whenever the app is on screen: on focus, on becoming visible, and every ten seconds between. It asks the pool whether each relay the inbox and the rooms listen on still holds a live socket (a dial hung past 20s counts as dead, and a relay that closed a subscription on us counts too) and rebuilds the lot when one is missing, dropping a stuck relay first so the rebuild dials afresh. Rebuilds retry on a growing backoff while a relay keeps refusing. tools/chat-watchdog-test.js errors every socket under an open room and checks the recovery. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A pack link in chat adds the pack in place; coinos has its own share link.Adam Soltys2026-09-151-14/+39
| | | | | | | | | | | | | | | | | | Tapping "Emoji pack" on a phone opened Vector: the chip was an outbound link to vectorapp.io, a domain the Vector app owns. The chip is now a button — one tap adds the pack right here, nothing leaves coinos. Packs also get a coinos share link, v3.coinos.io/emojis/pack/<naddr> (copy it from the picker's pack row). The Android app already claims every v3.coinos.io path, so on a phone that link opens coinos, which adds the pack once the wallet is open. Pack links from any host render as the chip; the naddr's kind is what's checked. Pack changes now save at once (learned shortcodes stay coalesced) — a remove followed straight away by a navigation was lost to the debounce. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* A reply's quoted line shows its custom emoji as pictures.Adam Soltys2026-09-151-6/+30
| | | | | | | | | | The quote above a reply bubble, the composer's reply bar, and the same on the public /chat page rendered the raw :shortcode:. They now run the one-line snippet through the same NIP-30 tokenizer, cutting on text so a shortcode is never sliced in half. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Custom emoji in chat: :shortcodes: render as pictures, packs sync with Vector.Adam Soltys2026-09-151-26/+353
| | | | | | | | | | | | | | | | | | | | | | | A message from Vector says :pika_wave: and carries a NIP-30 ["emoji", code, url] tag for it; coinos showed the bare shortcode. Now the bubble shows the picture (in rooms, DMs, the feed and the public /chat page), a bubble that is only one to three of them shows them big, and a :code: reaction chip is a picture too. Sending: every :code: you type that resolves travels with its tag, so the other side renders it whether or not they hold the pack. Shortcodes come from packs you hold (NIP-51 kind 30030 sets) and from what you've seen in chat, so anyone can answer a :pika_wave: with one. The composer autocompletes on ":pi" (Tab/Enter takes the first), the reaction picker lists custom emoji first, and Vector's pack share link renders with an Add button; a pasted naddr works too. Packs live on your kind 10030 list — the one Vector reads and writes — so a pack added in either app appears in both. tools/emoji-test.js drives the real coinos room headlessly and checks all of the above. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* Opening a reply scrolls the thread to that reply.Adam Soltys2026-09-151-1/+18
| | | | | | | | | | A thread opened from a reply highlighted the reply but never scrolled to it, so in a long thread it sat below the fold and the page looked like it had missed. The thread now scrolls the focused note into view after its paint — and if the replies are still loading, after the render that brings them. Opening the root itself means the top. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* An @handle in a bio or post opens the people search for that name.Adam Soltys2026-09-151-1/+21
| | | | | | | | A bare @name carries no key, so nothing can resolve it for certain — but it can be a tap that opens the search with the name filled in. The @ inside an email or nip05 is left alone, as is anything inside a URL. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* A gift locked from Savings shows under Savings, not Spending.Adam Soltys2026-09-142-2/+3
| | | | | | | | | Every feature balance line landed on the Spending face, but a gift's locked reservation is on-chain money still sitting in Savings. Lines now say which side they belong to and draw on that card: gifts on Savings beside pending incoming, ark's Moving… on Spending as before. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Join suggests communities from the Vector Hub, searchable, beside the paste box.Adam Soltys2026-09-141-7/+60
| | | | | | | | | | | The Join panel used to be one empty box asking for a link nobody had. It now lists the Vector Hub's public communities under it — icon, name, member count, category, description, sorted by size, marked when you're already in one — fetched on demand and kept for the session. The same box takes a link or filters the list; a tapped suggestion previews exactly like a pasted invite, since every listing is a CORD-05 link. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Invite links open in Vector, communities sync across Concord clients, and ↵Adam Soltys2026-09-141-35/+213
| | | | | | | | | | | | | | | | | | | | | | | | | | | any emoji can be a reaction. Vector's bundle parser has no default for a channel's key and epoch, so a coinos invite — which sent only id and name — failed its whole JSON parse and the link did nothing there. Every channel entry now carries key and epoch (a public channel's key is the community_root at the base epoch, CORD-05 §1), and bundles minted before this are re-signed at the same coordinate once per device, so links already shared start working. Concord clients now keep their Community List as kind 33302 fragments (CORD-02 §8): addressable per index, NIP-44 to self, 32-byte values as unpadded base64url, embedded join material without its community_id, seed omitted when it equals current. coinos read and wrote only the retired single-event 13302 on its own two relays, so a community joined in Vector under the same key never appeared here. The fragmented list is now read from our relays, the stock CORD set and every held community's relays, merged, and written back in the same shape; the home view re-syncs after a minute and a live subscription brings a fresh write straight in. 13302 stays for older coinos devices. The reaction row keeps its quick emojis, recents first, and a + opens a searchable picker of a few hundred more — eyes included. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* A picture can be sent to a channel or a DM, encrypted, and read by Vector.Adam Soltys2026-09-141-7/+154
| | | | | | | | | | | | | | | The composer grows a paper clip. A picked file is encrypted under a fresh AES-GCM key and 16-byte nonce, the ciphertext uploaded to a public Blossom host that takes anonymous blobs (blossom.ditto.pub, then nostr.download as a fallback copy), and the pointer posted the way Vector posts it: a kind-9 with empty content and a NIP-92 imeta tag carrying url, mime, key, nonce, both hashes, size and dimensions. In a DM it is a NIP-17 kind-15 file message with the same fields as flat tags and the url as content, wrapped to the peer and to ourselves. Kind-15 file messages are now accepted on receive too, rendered like any attachment, and previewed as a photo in the thread list. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* A picture sent to a channel shows up as a picture, not an empty bubble.Adam Soltys2026-09-141-1/+86
| | | | | | | | | | | | | Vector attaches files as NIP-92 imeta tags: the blob is encrypted with a one-off AES-GCM key before it reaches Blossom, and the tag carries the url, mime, key, nonce, plaintext sha256 and dimensions. Such a message has no text, so the bubble rendered blank. The blob is now fetched (fallback hosts too), decrypted in the browser, checked against the hash, and shown from an object URL — a grey box its shape while it loads, tap to view full size, other file types as a download by name. The same tags render in DMs. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Logging out leaves one identity, not everyone on the device.Adam Soltys2026-09-141-7/+18
| | | | | | | | | | | | With several identities signed in, the profile's Log out now signs out only the current one: its accounts leave the session, directory, cache and vault, the others stay put, and the next identity takes the screen. The confirmation names who is leaving and says whether they can sign straight back in (a nostr login) or need their recovery phrase (a bare seed). Logging out of every account is a second, explicit button. Alone on the device, Log out is unchanged. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* A community whose invite grants no channels still shows its public ones.Adam Soltys2026-09-141-0/+12
| | | | | | | | | | A Vector invite carries only private channel keys, so its channel list can be empty; the public channels arrive through the Control fold. The room only ever subscribed to the invite's channels, so FOSS Apps folded to 58 members and "No messages yet". Channels the fold discovers are subscribed as they land, and warm from the message cache on reopen. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* A community that has been refounded reads and writes at its current epoch.Adam Soltys2026-09-141-26/+74
| | | | | | | | | | | | | | | | | | | | | | | | Vector communities (Nostr Talk, FOSS Apps) sit at root epoch 1 after a refounding. coinos derived every channel stream at epoch 0, so it read and posted to an address nobody else used: five messages from coinos users where Vector showed months of history. Public channels now follow the base epoch (CORD-03), private ones their own key and epoch from the join material; the epoch tag on every rumor matches the stream that carried it. The Control Plane is read at the invite's control_pk (CORD-02 §5 split) and decrypted under the root-derived key, so metadata, icon and channel definitions fold for a post-split community; the legacy derivation is kept for the built-in coinos community, which still writes through it. Guestbook snapshots (3312) from the owner seed members after a refounding, so the count matches Vector's. Join material carries control_pk and private channel keys through the Community List and direct invites, and the list merge heals an entry that predates them. A pasted invite previews under the Communities heading where the paste box was, instead of at the top of a page the user had scrolled past. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Messages sent while the app was away arrive when it comes back.Adam Soltys2026-09-141-16/+53
| | | | | | | | | | | | | | | | | Two ways a DM or a room message went missing. A phone freezing the tab errors the relay sockets, and an errored socket is one the pool gives up on — so every wrap subscription died silently and nothing sent after the freeze ever arrived, until a reload. Coming back from the background (or back online) now rebuilds the DM and room subscriptions; their windows overlap what is held and seen wraps are dropped before any decryption. And a remote signer that never answered was read as "this wrap isn't ours": three such silences and the wrap was remembered as seen and lost on that device for good. Silence now means "try later" — uncounted — and the drain stops at the first one rather than burning tries on the rest. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A remote signer that drops its socket comes back by itself.Adam Soltys2026-09-141-1/+22
| | | | | | | | | | | | | | | | "My signer keeps disconnecting": a phone freezing the tab cut the relay socket the bunker session talks through, every request after that hung to its deadline, and the app declared the signer gone — a Reconnect button, tapped over and over. Three changes: the bunker client now talks through a pool that pings and reconnects on its own, so a dropped socket is re-opened and the subscription re-fired without anyone noticing; a request that does fail on a dead connection drops the session and dials again silently, so the notice only appears if that resume fails; and coming back from the background probes the signer with a quick question, replacing a dead session before the next real request runs into it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A knight's helm, for one small knight.Adam Soltys2026-09-141-0/+20
| | | | | | | | | | A gifted hat that covers the whole face — its own square drawing over the avatar rather than perched on it: steel great helm, cross slit, breathing holes, a red plume. Never sold; the registrar's gift endpoint knows it beside the beta hard hat. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A new identity starts with an empty Notifications list.Adam Soltys2026-09-141-0/+4
| | | | | | | | | | Logging out and opening another account kept showing the last account's replies and zaps: the list lived in memory across the switch, alongside the feed and tallies that were already being cleared. It is cleared with them now, together with the who-zapped map and any open panels. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A reply or a mention on nostr can buzz your phone.Adam Soltys2026-09-141-1/+23
| | | | | | | | | | | | | The push notifier now watches kind 1 for the user's pubkey beside DMs and zap receipts, and pushes who, what, and an excerpt. The worker shows "New reply to your post" or "You were mentioned" with the text, and a tap lands in the Notifications list — as ?open=notifs when a window has to be opened, or as a worker message when one already is. It has its own switch, "Replies and mentions on nostr", in Settings → Notifications, on by default like payments and DMs. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A zap on a chat message shows the message, not "Fetching note…" forever.Adam Soltys2026-09-141-8/+41
| | | | | | | | | | | | A receipt for a zap on a chat message or a DM e-tags the rumor's id, which no relay has ever held as an event — so the row fetched it by id and waited forever. The rooms and threads we hold are searched first: the row shows the message's text and a tap opens that room or thread. A post the relays really don't have says so instead of spinning. And a coinos zap's own receipt beside the LNURL server's is one row, not two. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A notification's excerpt names the people it mentions.Adam Soltys2026-09-141-2/+9
| | | | | Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* The zap amounts in the new panels use the feature's own formatter.Adam Soltys2026-09-141-2/+2
| | | | | Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* Who reacted, and what happened to your posts.Adam Soltys2026-09-141-5/+247
| | | | | | | | | | | | | | | | | A chevron on a post's action bar, shown once anyone has reacted, boosted or zapped it, opens a panel that says who and with what: each zap on its own line with the amount and any message, then each emoji with the faces and names behind it, then the boosts. Every face opens the profile. The zap tally now keeps who sent each receipt, not just the sum. And a Notifications list on the chat home, under the feed: every like, boost, zap, reply and mention that names you, one filter on the relays we read, painted from last time while the relays are asked and live while open. A row opens the post it is about — or the reply itself — and the entry counts what arrived since you last looked. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A video keeps playing while the page repaints.Adam Soltys2026-09-141-0/+7
| | | | | | | | | | | | The page repaints in the background all the time — a zap count, a profile landing, the thirty-second sync — and every repaint rebuilt a post's YouTube box as poster-plus-play-button and patched the live box into that shape, which threw the playing iframe out a few seconds into every video. Once playing, the box is the viewer's: it is frozen against the morph and left exactly as it stands. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* The balance says everything it has to say in one card.Adam Soltys2026-09-141-26/+33
| | | | | | | | | | | | Under the Spending balance, warnings stacked up as separate boxes — two red cards and a grey line, with three Manage links between them, all pointing at the same page. They are one card now: red if anything in it is a warning, plain otherwise, each notice on its own line, and a single Manage at the bottom right. A lone quiet advisory (the exit-cost line most wallets show) keeps its centred grey line. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A new post opens up instead of appearing.Adam Soltys2026-09-141-2/+46
| | | | | | | | | | | | | | | | | | | | | | A post let in at the top of an open feed was there on the next paint — one frame nothing, the next a whole row, the page shoved down by that much. Now it opens over a third of a second: height from nothing to its own, fading in as it goes, and a repaint mid-way resumes where it was. The same happens to the posts the pill lets in. That needed the morph to know which row is which. Children were matched by position, so a new first row was patched onto the old first row's node and every row below onto its neighbour's — the node the new post was built as never reached the page. A list whose every child carries a data-key is now reconciled by key: new rows are inserted as themselves, the rest keep their nodes and move. Feed rows and their hairlines carry the post id. Partially keyed lists keep the positional rule. feed-live-test catches the arriving row mid-animation: shorter than it will be, one animation running, and settled clean afterwards. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A post arrives whole.Adam Soltys2026-09-141-5/+68
| | | | | | | | | | | | | | | | | | A feed row painted the moment its event landed and then finished itself over the next second or two: a punk turning into a photograph, a blank gap becoming the picture. Now a post waits at the door until its author's profile has landed and their face and the images in its body are fetched and decoded — bounded by two seconds, so a slow host holds a post briefly rather than the feed forever. This covers the first load, live arrivals (which are counted behind the pill only once whole), paging older posts, and profile pages. The cached page at boot still paints instantly and warms in the background. feed-live-test serves a deliberately slow picture and checks, from the first instant the row exists, that its image is already decoded. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A zap answers as fast as the route allows.Adam Soltys2026-09-142-18/+42
| | | | | | | | | | | | | | | The wallet used to add two to four seconds of its own around a Lightning send: a random pause plus a relay look-back before paying, and a two-second poll for the result after. The notifier's claim now decides who pays in one round trip, with the relay look-back kept only for replays and for when the notifier gives no verdict; a phone answering from behind another app waits a second so a desktop tab wins the claim instead of paying at a throttled crawl. After the send, one held CheckLightningPayment call brings the preimage the moment CLN has it — for wallet-connect pays, the background worker, and the app's own Send. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* One device pays a zap, whichever of them heard it first.Adam Soltys2026-09-141-2/+27
| | | | | | | | | | | | | Every awake device of a wallet hears a wallet-connect pay request within the same second, and the per-tab lock only reaches one browser profile: two profiles on one laptop each locked an HTLC for the same 21-sat zap. The push notifier now hands each pay request to the first device that claims it; a device that loses the claim stands down silently, and a device that cannot reach the notifier answers as before so nobody goes quiet over a hiccup. The worker path claims the same way. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* A zap answered by two tabs no longer reports as failed.Adam Soltys2026-09-142-5/+43
| | | | | | | | | | | | | | | Every open tab of the same wallet sees every wallet-connect request. Two hidden tabs on one laptop both tried to pay the same zap: the ASP refused the second with "payment already in progress", and that error reached Amethyst half a second before the first tab's preimage. A Web Lock per request now lets one tab (or the worker) answer while the rest stay quiet, and a device that does meet "already in progress" waits for the sibling payment's outcome — relaying its preimage only if the payer never speaks — instead of shouting a failure over sats that went through. The losing side also stops writing a failed row into history for it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
* The renewal happens whether or not anyone opens the app.Adam Soltys2026-09-131-1/+44
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every coin expires, and until now the only thing standing between a balance and its expiry was maybeAutoRefresh: it renews when the wallet is opened inside the last 96 blocks, roughly sixteen hours. Bark's own developers say the same premise fails on mobile — people don't reliably open a wallet at the right hour, which is why every mobile Ark client they know of has moved to delegated refreshes and why they are building server-driven renewal. captaind already has it, and ark.coinos.io has been carrying the column unused: SubmitRoundParticipation takes a scheduled_height, and the server holds that participation out of every round until the height arrives, then runs it with nobody online — the round tree branch is signed by the server, which is the whole point of a delegated refresh. The fee is priced at the height it will run at, so a standing order lands in the free bracket rather than today's. So each spendable coin now gets one registered 36 blocks before its expiry, six hourly rounds of slack. It is deliberately not an action: the coin stays spendable, the wallet never reads as busy, and spending it simply kills the order — the server drops a participation whose input is gone, and _driveScheduled drops ours. The claim comes later, whenever the wallet next opens: the mailbox breadcrumb we already post wakes it, and the leaf cosign and forfeit are the ordinary refresh path from there. Nothing about the input's expiry blocks that claim, which is what makes this worth having. A wallet that goes quiet for a month comes back to a coin already renewed and anchored in a fresh tree, instead of one the ASP has swept and would have to honor out of goodwill. One cycle per visit is all it can buy — the replacement's id doesn't exist yet, so its own backstop waits for the next time the app is opened. tools/ark/scheduled-refresh-test.js covers the five behaviours on regtest: the server holds the order until its height, runs it while the wallet is closed, the wallet claims on return, a spend voids the order, and a claim still works after the input has EXPIRED (the last one mines a coin's whole lifetime, so it also re-refreshes the funding wallet first — an expired vtxo can be renewed but not sent). Riding along: the expired-coin notice that was already sitting in the working tree, and manager-test's tampered-vtxo check, which had been reading base64 vtxo bytes as hex since they moved to IndexedDB. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019fMdLypdg5JaU7bdeUFF8v
* The feed reads the relays it was already willing to open.Adam Soltys2026-09-131-13/+35
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The gap was not where I said it was. "283 of 400 authors have no relay list" sounded like the thing to fix — but those authors turned up in the sample BECAUSE our own relays carry them, and outboxPlan already asks our relays about everyone. The legacy kind-3 relay map, the fallback I proposed for them, recovers 2 of 220. Neither is worth writing. The real gap is that covering every author once was the wrong goal. People publish to several relays and each holds a different slice: of 60 sampled authors with a write list, 45 declared MORE than the three we took, and seven of twenty-five had posts living only on their fourth or later relay. Meanwhile the greedy stopped the moment everyone was covered — nineteen sockets of a twenty-four socket budget, with posts sitting unread on the five it never opened. So it takes six relays per author instead of three, and keeps picking until the budget is spent rather than until coverage is met. Measured over the same 250 authors, twice: 1265 notes before, 1549 after, 502 of them notes the old plan never saw. Same socket count we already allowed. Raising the per-author number ALONE makes it worse, which is worth knowing: more relays per author let a few fat ones cover everybody in nine sockets, and the feed read less than before (1089 against 1312). The two changes only work together. feed-follow-test now seeds one author with six write relays and asserts all six are dialled, so neither knob can drift back on its own. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Markdown in a post reads as what it means.Adam Soltys2026-09-131-22/+46
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A kind 1 is specified as plain text — NIP-10 says markup SHOULD NOT be used — but bridges and cross-posters emit it anyway, and of the three things we could do with it we were doing the worst: the URL inside ![cover](…) rendered as a picture while the "![cover](" sat above it as litter. That is a real post from a real account, not a hypothetical. Images and links, and nothing else. ![alt](url) is a picture, [text](url) is a link that reads as its text. Headings, bold and italics are deliberately NOT honoured: # opens a hashtag and * turns up in ordinary prose, so supporting those would mangle normal posts to tidy up the rare bridged one. One thing markdown gives us that guessing never could: ![…] is the author stating outright that the URL is an image, so a picture on a CDN path with no file extension now renders as a picture instead of a bare link. The URL rendering is one function now rather than a branch inside the splitter, since a markdown link and a bare link deserve the same answer about what they point at. tools/markdown-note-test.js covers both shapes the real post came in (including the newline after the paren), the no-extension case, and — the one that matters most — that brackets, parens, hashtags and stars in ordinary prose are left exactly as written. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed keeps up, and stops moving the page while you read.Adam Soltys2026-09-131-10/+91
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A phone freezes a backgrounded tab: timers stop and the relay sockets are cut, so the feed came back holding whatever it had when you left and only caught up if you pulled it down by hand. Features now hear about the return — app.js calls f.resumed(awayMs) on focus — and the feed answers it by forcing a pass past the thirty-second throttle and rebuilding its subscriptions, which were talking to sockets that no longer exist. A blink between apps (under five seconds) is left alone. That catch-up raises the second problem rather than solving it: dropping eight posts in at the top moves the words under the thumb of whoever was reading. So posts that arrive on their own — live, or as a catch-up — wait when you're scrolled away from the top. A floating pill says how many, and the tap that lets them in also takes you up to them. Scrolling back to the top by yourself lets them in too, since up there they cost nothing. Anything you actually asked for still goes straight in. Also, on profiles that still show an npub: the batch now falls back to the relays whose whole job is indexing kind 0 (purplepag.es and friends), which we never read notes from and so never asked. profile-batch-test's "handful of asks" bound was a fraction of the row count, which that fallback broke; the property it means to assert is that the number does NOT grow with the rows, so it is a constant now. tools/feed-live-test.js drives a real signed event down a real open subscription — an unsigned fake is dropped by the signature gate and would prove nothing — and checks the page does not move, the pill says "1 new post", tapping it scrolls to the top, and coming back from hidden re-asks the relays on its own. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* 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