| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
| |
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 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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"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 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
|
| |
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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 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
|
| |
|
|
|
| |
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
|
| |
|
|
|
| |
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYJfZ4A1TDCEY4Foq3CApR
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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 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 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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
 rendered as a picture while the " 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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tested on a real phone, and the first attempt didn't work at all:
Background started FGS: Disallowed [... code:DENIED ...]
startForegroundService() not allowed due to mAllowStartForeground false
Android 12 forbids starting a foreground service from a broadcast receiver.
An expedited job is the sanctioned replacement — it runs promptly from the
background and needs no notification of its own to apologise for existing —
so WakeService is now WakeJob, and the notification is decided by what
arrived rather than by whether we could wake: a payment or a message is news
either way, a wallet-connect request the wallet answers by itself is not.
Two more things the phone found. A job with a connectivity constraint needs
ACCESS_NETWORK_STATE, or schedule() throws and nothing runs at all. And a
wake with nothing to answer used to hold a WebView open for the full thirty
seconds — a locked wallet, no wallet on the device, or no NWC connections all
end the job immediately now.
Also a bug worth naming: ctx carried `wakeDone` by shorthand, which captured
the binding while it was still undefined (ctx is built before it's defined).
The page could never say it had finished, so every wake ran its full timeout.
It reads through a closure now, like its neighbour.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The TWA can't. Chrome renders the site, so the wallet's keys live in Chrome's
storage for its own origin, and nothing outside Chrome can reach them — a
UnifiedPush message could raise a notification and no more. On a phone with
Google Play Services none of this matters; without it, background answering
was simply unavailable.
So: a second flavour, io.coinos.webview, "Coinos (UnifiedPush)". The wallet
runs in a WebView this app owns, which means a push can start it off-screen
and let it answer for itself. WakeService loads it with ?wake=<payload> into
the same storage the visible app uses, the page subscribes and answers
whatever the payload was about, then calls CoinosHost.done() so the service
stops — capped at 25 seconds from the page and 30 from the service, because
nothing here is allowed to sit in the background costing battery. Its
foreground notification says what it's doing rather than apologising for
existing.
The WebView carries what the wallet actually needs: DOM storage and
IndexedDB, the camera for the QR scanner (granted only once Android has
granted it to us), a file chooser for attaching pictures, and links to
anywhere but coinos.io handed to a real browser.
The trade is named in the code and worth naming here: a separate app is a
separate wallet. Its storage is its own, so signing in there is signing in
again. That's why this is a second build rather than a replacement — the TWA
is untouched, still the better browser for anyone whose phone has push.
The shared push receiver now asks the system for whatever front door this
build has, and asks a per-flavour Wake class whether the wallet can be woken
at all — no flavour checks in the middle of it. Both APKs build and ship:
coinos.apk as before, coinos-unifiedpush.apk beside it.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
"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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 balance read "US$74.67 USD" — the symbol and the unit tag saying the same
thing twice. Amounts now format bare, like sats and BTC do: whatever labels
the number says which money it is. The currency symbol stays where a number
stands alone in prose, which is the "Worth then" line and nowhere else.
The '~' marking a payment older than any price we sampled is gone too. It was
bookkeeping the beta doesn't need: a payment we have a price for is shown at
that price, one we don't is shown at today's, and nobody has to learn a
squiggle.
The currency picker has moved to sit beside the language picker at the foot
of the page — display preferences together, where they're findable. It spent
one day under Settings → Network, where it wasn't.
And a bug this feature exposed rather than caused: nine notices end in the
word "sats" in every language we ship, and were being handed the display
unit. "Uncooperative exit costs about US$2.03 sats" — and long before fiat,
"about 0.00435397 sats" for anyone in BTC. They're given sats now, which is
what they claim to be showing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A payment is worth what it was worth WHEN IT HAPPENED, and that number is
gone by the time you look at it unless someone wrote it down. So every price
the app fetches is kept as a sample — one an hour, a few months back — and a
payment's fiat value is read off the sample nearest its own timestamp. The
history rows and both detail pages (on-chain and Spending) pass the time the
money moved; balances and forms use the live price, as they should.
A payment older than any sample we hold is priced at today's number and says
so with a '~', because that is a different claim and shouldn't look like the
same one. The detail page spells it out: "Worth then $X · $Y now".
Fiat is a third position on the unit toggle rather than a second number
beside the first: sats → BTC → your money → sats. One figure, and which
figure is your choice. The ring skips fiat entirely until a price has been
fetched — a third position showing blanks is worse than two that work. Typing
an amount with the unit on USD means dollars, priced now.
The rates are coinos' own endpoint, a map of every currency it knows, so
there's no third party watching what the wallet asks about; the currency
picker under Settings → Network lists all of them. Changing it starts the
recorded history again rather than relabelling samples taken in another
currency.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| | |
|
| | |
|
| | |
|
| | |
|
| | |
|