Commit Graph
17770 Commits
Author SHA1 Message Date
Gigi 4cb0fb64af fix: decode numeric HTML entities in meta tag content
Link previews left " / " literal because replaceCharRefs only
whitelisted named entities. Parse terminated numeric refs as code points.

Fixes #3723
2026-07-26 23:52:33 +02:00
Vitor PamplonaandGitHub 38b085812d Merge pull request #3721 from vitorpamplona/fix/tor-guard-sample-self-heal
fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk
2026-07-26 17:20:43 -04:00
Vitor PamplonaandGitHub 871d56e1a4 Merge pull request #3717 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-26 17:20:15 -04:00
Vitor PamplonaandGitHub 221cb84408 Merge pull request #3720 from vitorpamplona/claude/bolt12-zaps-nip-naming-qzw8zi
Rename NIP-XX to NIP-B1 for BOLT12 Zaps specification
2026-07-26 17:20:04 -04:00
vitorpamplonaandgithub-actions[bot] 6bf3c11173 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-26 21:18:45 +00:00
Vitor PamplonaandClaude Opus 5 e934d41bb2 fix(tor): self-heal a guard sample that is rotten at runtime, not just on disk
Tor wedged across app restarts with ~87% of relay connections failing, and
neither existing recovery fired. Captured from the device:

  guards.json  default: 60 guards, 59 disabled, 1 unlisted -> 1 usable
  Arti log     AllGuardsDown { n_accepted: 0, n_rejected: 60 }  x21,490
  sockets      36,007 failures vs 5,276 successful opens

Everything above Tor degraded with it. Concord was the visible casualty: its
control-plane sync drained exactly 12 wraps on every launch and never folded, so
the Messages tab showed zero Concord rows for two restarts running.

Two recoveries exist and this state slipped between both:

- `ArtiGuardState.hasNoUsableGuards` required `usable == 0`. Its own KDoc claimed
  "a single usable guard is enough to recover" — the device disproved it. One
  survivor that is merely *unreachable* is useless for recovery but sufficient to
  veto it, so the wipe never ran.
- `TorManager`'s watchdog only arms while status sits at Connecting. Tor had
  bootstrapped: the SOCKS proxy was bound, `hasEverBootstrapped` was true, ~13% of
  connections still worked, so status reached Active and the watchdog never fired.
  It is built for "Tor never came up"; this is "Tor came up and its guards rotted".

Nothing observed the steady-state failure rate, so all three gates evaluated the
same way on every launch and `guards.json` carried the wedge forward forever.

1. Proportional disk rule. A sample of at least MIN_SAMPLE_TO_JUDGE_RATIO whose
   usable guards fall under 1/USABLE_RATIO_DIVISOR of the total is wedged. Below
   that size only the strict `usable == 0` rule applies, so a young sample Arti is
   still filling is never wiped out from under a legitimate first bootstrap.

2. Runtime detector. `TorService` counts Arti's own AllGuardsDown log lines
   (GUARDS_DOWN_THRESHOLD within GUARDS_DOWN_WINDOW_MS) and exposes
   `TorBackend.guardsDownSignal`; `TorManager` routes it through the same
   rate-limited self-heal to `resetWithCleanState()`. This is the general fix: it
   believes Arti when it says every guard was rejected, so it fires even while
   `guards.json` still looks healthy and status is Active — precisely the blind
   spot between the two older heuristics. The count lives in the log callback
   because that is the only place Arti surfaces it; the reset stays in TorManager,
   which owns the cadence.

Verified on the wedged device. Next launch, unprompted:

  W TorService: No usable Arti guards left on disk — wiping state to rebuild the guard sample

  guard sample   60 total / 1 usable   ->  20 total / 20 usable / 0 disabled
  AllGuardsDown  21,569                ->  0
  sockets        36,007 fail / 5,276   ->  444 fail / 232 open
  Concord wraps  12, 12, 12 (pinned)   ->  12 -> 37 -> 258
  Concord rows   0                     ->  4 and climbing

Tests cover the 59-of-60 field case, a small-sample false-positive guard, and a
healthy-majority sample. The pre-existing 22-guard "poisoned but not wedged"
fixture still passes, so the ratio does not regress the earlier variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 17:18:26 -04:00
Vitor PamplonaandGitHub dfbe2b9e61 Merge pull request #3718 from vitorpamplona/fix/buzz-minichat-kind9-replies
fix(buzz): thread kind-9 replies into the minichat instead of the channel
2026-07-26 17:16:04 -04:00
Vitor PamplonaandGitHub eedc8c7a08 Merge pull request #3719 from davotoula/fix/audio-player-overflows-note-layout
Stop the inline audio player painting over the note
2026-07-26 16:43:33 -04:00
Claude 67223d612f refactor: rename BOLT12 zaps placeholder NIP to the assigned NIP-B1
The BOLT12 zaps feature was built against a placeholder NIP identifier
("nipXX" / "NIP-XX", and "NIP-2421" in one plan). The number NIP-B1 has
now been assigned, so update the naming across every module:

- rename the Quartz package `nipXXBolt12Zaps` -> `nipB1Bolt12Zaps`
  (commonMain + commonTest) and every import referencing it.
- KDocs/comments: `NIP-XX` -> `NIP-B1` in quartz, commons, amethyst, cli.
- wire binding prefix: `nostr:nipXX:` -> `nostr:nipB1:`
  (Bolt12ZapValidator.NIP_URI_PREFIX, NIP-47 pay `payer_note`, tests).
- KindNames: Bolt12 Zap / Bolt12 Offers NIP number "XX" -> "B1".
- plan doc references `NIP-2421` -> `NIP-B1`.

Leaves the unrelated `nipXXPodcasting20` package and the audio-rooms
draft (also placeholder "NIP-XX") untouched. quartz main + test compile
and spotless is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012P3krSe92wicpswBr2CP9s
2026-07-26 20:32:16 +00:00
Vitor PamplonaandClaude Opus 5 07e3f21509 fix(chats): light the Messages badge for every row type, notify Buzz thread replies
Two halves of the same gap: a row could show its blue dot while nothing above it
agreed.

1. Bottom-bar envelope counted only DMs
------------------------------------------------------------------------------
`messagesHasNewItems` mapped each Messages row through `unreadPrivateChatRoute`,
which opens with `if (newestMessage !is ChatroomKeyable) return null`. Only
NIP-17/NIP-04 DM events implement that interface, so eight of the nine row types
were silently skipped — public chats, ephemeral rooms, geohash cells, Marmot
groups, NIP-29/Buzz channels, Concord channels, and both collapsed "grouped"
rows. Each of those rows already computed its own dot from its own last-read
route; the badge just never asked.

`rowHasUnreadFlow` now answers, per row, the same question the row composable
answers for itself, keyed off the note's gatherer (a Buzz channel and a Concord
channel can both carry a kind-9, so event kind alone can't tell them apart). It
returns a Flow rather than a (route, createdAt) pair because the two collapsed
rows fan in over every child channel — approximating those by their newest child
would miss an older channel that is still unread.

2. Buzz thread replies notified nothing
------------------------------------------------------------------------------
Buzz's clients thread with `["e", <id>, "", "reply"]` and only ever `p`-tag
@mentions, so a reply to my message names me nowhere. `isNotifiablePublicChatRep
ly` — the rule that lets a reply notify without a `p` tag — bails unless the
event is a ChannelMessageEvent (kind 42), so a kind-9 thread reply qualified
under nothing. Combined with thread replies now being kept out of the channel
timeline and its unread dot, a reply to my message in a Buzz channel had become
invisible on every surface.

`isBuzzThreadReplyToMyEvent` mirrors the fix already used for Buzz reactions
(`isReactionToMyEvent`): when a chat event carries no `p` tag, resolve the author
of its `root`/`reply` marked `e` targets instead of trusting a tag. Deliberately
only the MARKED targets — a bare `e` is WhiteNoise/Marmot's in-chat reply, not a
thread. It is OR'd into the same three gates the reaction case uses, so a reply
from a channel member I don't follow still notifies.

Also admits kind-40002 into NOTIFICATION_KINDS: nothing writes it any more, but
legacy Buzz thread replies exist and were being dropped at the kind gate before
any relevance check ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:22:22 -04:00
davotoulaandClaude Opus 5 3813e248c2 i18n: translate Buzz, Blossom import and BOLT12 strings into cs, de, pt-BR, sv
Fills the on-disk translation gap for the recently merged Buzz workspaces,
Blossom file import and BOLT12 offers features, plus the channel-invite and
payment-notification strings.

Inserted per-locale (128 cs, 124 de-rDE, 126 sv-rSE, 128 pt-rBR) driven off
each file's own diff rather than a shared union block, since Crowdin strips
source-identical keys asymmetrically across locales.

All 7 new <plurals> are covered in every locale; Czech gets the full CLDR
one/few/many/other set. Source-identical entries (Canvas, Workspace, OK,
LIVE, Media, Social, Reposts, and the bare %1$d+ count formats) are
deliberately left out — Crowdin strips those on export and Android falls
back to values/strings.xml at runtime.

Verified: no duplicate keys, well-formed XML, and format-placeholder parity
with the English source across all four files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T45dBdJ8GRBy8zw9yDQRPW
2026-07-26 22:21:24 +02:00
Vitor PamplonaandClaude Opus 5 4f6e16a74e fix(nip29): line up preview, timeline and unread on one predicate
A minichat thread reply was showing as a group's "last message" on Messages, and
lighting its unread dot, even though the channel timeline correctly hides it.
Three surfaces disagreed about what counts as a message:

  channel timeline  ChannelFeedFilter   !isMinichatReply(..) && isAcceptable
  Messages preview  newestChatNote      isGroupChatContent()  && isAcceptable
  unread dot        hasChatNewerThan    isGroupChatContent()  && isAcceptable

So a reply that the channel deliberately routes to its thread still became the
row summary, and still lit the dot — you would open the group, see nothing new,
and watch the dot clear. The Concord side already solved exactly this by sharing
one predicate (`isConcordTimelineMessage`) across feed, preview and badge; this
gives NIP-29 the same treatment:

    isRelayGroupTimelineMessage = isGroupChatContent && !isMinichatReply && isAcceptable

Now used by:
- `newestTimelineNote` (replaces the local `newestChatNote`) for the row preview,
  in both INLINE and GROUPED view modes
- both additive paths in ChatroomListKnownFeedFilter, so an arriving reply cannot
  bump a row either
- `hasChatNewerThan`, which backs the per-channel dot (`relayGroupChannelHasUnread
  Flow`, also used by the workspace channel list), the collapsed per-relay dot
  (`relayGroupServerHasUnreadFlow` composes it), and transitively the Messages row
  dot, which reads the createdAt of the note the preview picked

The bottom-bar Messages badge follows automatically: it derives from the feed rows
themselves. (It only counts private DMs today — `unreadPrivateChatRoute` returns
null for anything that isn't ChatroomKeyable — which is a separate, pre-existing
scope decision, untouched here.)

Verified on device by creating the case rather than waiting for it: posted a reply
into a thread so it became the newest event in the channel. The thread shows it
(chip 4 -> 5 replies), the channel timeline does not, and the Messages row still
previews the newest timeline event. Before this change that reply would have been
the row summary. The earlier screenshot only looked right because a system message
happened to be newer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 16:00:49 -04:00
Vitor PamplonaandClaude Opus 5 ec0c4a6c2a fix(buzz): thread kind-9 replies into the minichat instead of the channel
A reply written by any current Buzz client is a kind-9 carrying a NIP-10
`reply`-marked `e` tag. Amethyst rendered it as a flat row in the main channel
with the parent quoted above it, and it never appeared in the thread on its
parent — the exact inverse of where Buzz puts it. Observed on the wire:

  parent  kind 9  tags: [h, <channel>]
  reply   kind 9  tags: [h, <channel>], [e, <parent>, "", "reply"]

`isMinichatReply` is the single definition three consumers share — the channel
timeline filter drops these, the reply-count chip counts them, and the minichat
feed shows them — but it was type-gated to CommentEvent (1111) and
StreamMessageV2Event (40002), so a kind-9 ChatEvent fell through to `false`.
Every downstream behaviour followed from that one gap: the reply stayed in the
timeline, the parent showed no "N replies" chip, and the thread was empty.

Accepting a marked `e` on kind 9 is NIP-C7 compliant. C7 defines exactly one
reply mechanism for kind 9 — `["q", <id>, <relay>, <pubkey>]` — and never
mentions `e` at all, so a marked `e` carries no C7 meaning and is free to denote
a thread reply. Matching on the MARKER (never the bare tag) is what keeps
WhiteNoise/Marmot working: they thread kind-9 chat with a plain, unmarked `e`,
which is an in-chat reply and must keep rendering as a quote bubble. Tests pin
all four cases: marked direct, marked nested (root+reply), unmarked, and `q`.

Also stop writing kind-40002 for Buzz minichat replies. Nothing in Buzz writes
40002 any more: every send path in their mobile, desktop and CLI clients emits
kind 9, the ~50 remaining references are all reads (filter kind lists, feed
query sets, archive constants), and their NOSTR.md grades kind:9 as supported
against 40002's "Buzz-only — no standard NIP-29 client renders these". It is a
read-compat tail from the 10002 -> 40001 -> 40002 migration, and Amethyst was
the last active writer — so our replies threaded nowhere but our own client.
We now emit kind 9 with tags byte-identical to Buzz's `_buildReplyTags`
(direct -> one `reply` marker; nested -> `root` + `reply`), which is what
`buzzThread` already produced. Reading 40002 stays supported for events already
in the wild, including the ones we wrote.

Deliberately NOT changed: `computeReplyTo`'s ChatEvent branch still links every
`e` tag to the parent. That link is what populates `note.replies`, which the
thread and the chip read — discriminating there would unlink Buzz replies from
their parents. The marker distinction belongs in rendering, not in linkage.

Verified on device against a real thread: the reply now sits in the thread on
"howd you get bumble working?" with a "3 replies" chip on the parent, and is
gone from the channel timeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:14:15 -04:00
davotoula 482be60e4a Code review:
- fold audioExt into videoExt and share the classifier
2026-07-26 20:26:52 +02:00
davotoula b2b2adf076 fix(audio): stop the inline audio player painting over the note 2026-07-26 20:23:15 +02:00
Vitor PamplonaandGitHub 537b75a451 Merge pull request #3716 from vitorpamplona/fix/nip29-buzz-channel-delivery
fix(nip29): deliver Buzz/NIP-29 channel updates live, and ask before showing channels you were added to
2026-07-26 12:59:31 -04:00
Vitor PamplonaandClaude Opus 5 bb87d7755a feat(buzz): ask before showing channels somebody added you to
On a Buzz relay, channel membership is server-side: another member can add you,
the relay writes you into the kind-39002 roster, and you can read and post
immediately. The relay then addresses you a kind-44100 naming who did it.

Amethyst funnelled every 44100 into BuzzDmChannels — treating it as a DM — which
silently subscribed you to that channel's messages, while the Messages list
(which reads the self-published kind-10009) showed no row for it. A channel could
therefore be joined, streaming, and invisible at the same time: the channel
screen offered no Join button and accepted posts, the RelayGroups screen listed
it from the relay's 39000 directory, messages arrived — and Messages had nothing.

Nothing here is auto-accepted any more. 44100 carries `{"type","channel_id",
"actor"}`, and the relay emits the SAME kind for a self-join with `actor == you`,
so the actor is the only thing separating "I joined this" from "somebody put me
here". Channels are classified by the `t` tag on their 39000 (stream/forum/dm/
workflow — read through a dedicated accessor because on buzz the type shares the
tag name with real hashtags): only `t = dm` belongs in the DM list, everything
else becomes a pending invite that subscribes to nothing.

The prompt appears on both surfaces, driven by one state holder so they cannot
disagree — Notifications, in the same header slot as the missing-inbox-relay
prompt, and Messages > New Requests, beside the pending DMs it is the exact
analogue of. Rendered as a list row rather than a modal: these arrive in bursts
when somebody sets up a workspace, and a blocking dialog on cold start would be
miserable. It is also the spam surface, so Ignore stays cheap.

Three actions, and Ignore is deliberately not Leave:

- Show    -> writes the group into kind-10009 (Account.follow), after which the
             ordinary joined-group path owns it and it syncs to other devices.
             No kind-9021: the relay already has you in the roster, so this
             records only your decision to surface it.
- Ignore  -> local, reversible display choice. You stay in the roster and can
             still open and post.
- Leave   -> kind-9022 LeaveRequestEvent, the one that actually removes you.

A kind-44101 removal now withdraws any pending prompt, so the relay taking the
membership away cannot leave a card offering an action that would fail.

The invites section is passed as the chatroom feed's header rather than stacked
beside it: the collapsing top bar draws over that area, so a header outside the
list renders underneath it. It shows in the empty state too, otherwise an account
with no pending DMs would have no way to reach the prompt.

Verified end to end on device: "straycat added you to personalized-knowledge-
graphs" rendered on both surfaces, and Show republished kind-10009 with the
channel appended.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:44:36 -04:00
Vitor PamplonaandClaude Opus 5 f434e98b00 fix(nip29): scope group subscriptions per channel so Messages updates live
The Messages list showed a stale last message for every NIP-29 group while the
open chat screen stayed live. The joined-group tail batched every group on a
host relay into ONE filter carrying all their ids in `#h`. That is valid NIP-01
and relays answer it correctly for stored queries — which is what made it so
confusing: the boot backfill populated every group, so the list looked right
until it needed to change.

`block/buzz` indexes each live subscription under a single channel uuid resolved
from `#h`. When two or more distinct ids appear anywhere across a subscription's
filters, `extract_channel_id_from_filters` returns None and the subscription is
registered as *global* — and global subscriptions deliberately never receive
channel-scoped events, guarding against leaking private channel content to a
subscriber whose membership was not checked per channel. So the batched tail
backfilled at EOSE and then went permanently deaf.

Measured on device: same relay, same connection, same subscription id, only the
`#h` count changed — one value delivered live, two delivered nothing.

The resolver scans every filter of a subscription, so splitting into one filter
per group is not enough; each channel needs its own subscription. The EOSE
managers are therefore keyed on GroupId, and the preloads mount one subscription
per joined channel. Relays leave room for this: buzz allows max_subscriptions
1024 against max_filters 10, so this also sidesteps the filter cap for anyone in
more than ten groups.

Also folded into the same per-channel subscriptions:

- Group activity addressed to me (reactions/zaps/replies) moved off the
  account-wide notifications subscription. That one also carries inbox filters
  with no `#h` at all, and buzz forces a subscription global on the first
  channel-less filter, so no reshaping there could ever have worked.
- Reactions and deletions (kinds 5/7/9005) now ride the channel's own `#h`
  subscription, the shape Buzz's own client uses (`channelEventKinds`). Amethyst
  otherwise learned about them only through the shared `#e` EventFinder query,
  which carries no `#h` and is therefore global — so reaction chips appeared
  only on a re-query, never as they happened. Kept out of the timeline kind set
  so reactions cannot consume a history page's limit and walk the `until` cursor
  past undelivered messages.
- A `limit = 1` preview filter with no time floor. The tail floors at now-7d to
  keep recent chat warm; on its own that stranded any channel quiet for longer
  on a "No messages yet" placeholder sorted to the bottom by createdAt 0. Every
  other roster-driven protocol on that screen already bounds by count for this
  reason (NIP-28 `limit = 1`, Concord `limit = 10`) — their row set comes from a
  list event, unlike NIP-17/NIP-04 whose rooms are discovered from the messages
  and so need the backward pagers.

The Messages row now says "Loading" instead of "No messages yet" until the fleet
settles, so an in-flight channel is not mistaken for an empty one. That required
the shared WindowLoadTracker to describe the whole joined fleet rather than
whichever channel rebuilt last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:44:05 -04:00
David KasparandGitHub a488c589f5 Merge pull request #3714 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-25 16:44:13 +02:00
vitorpamplonaandgithub-actions[bot] f088dade41 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-25 13:44:35 +00:00
Vitor PamplonaandGitHub 683d90763b Merge pull request #3715 from vitorpamplona/claude/relay-groups-card-design-7i9qak
Relay Rail: cluster relay groups by host relay with slim headers
2026-07-25 09:41:26 -04:00
davotoulaandClaude Opus 5 b07f8f203d refactor(chats): drop redundant !! on chat-message edit callback
The `if (canEditBuzz || canEditConcord)` guard already proves
`onWantsToEditChatMessage` non-null — both booleans are local vals whose
definitions begin with a null check, and K2 propagates that through them.
The `!!` compiled to an assertion that could never fire, and produced an
"Unnecessary non-null assertion" compiler warning.

No behaviour change: the smart-cast invoke is what was already happening.
If either guard is later loosened, this now fails at compile time instead
of becoming a runtime NPE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-25 12:44:50 +02:00
Claude 560c4fcff5 Revert "refactor: derive relay-group row preview from note cache, not lastNote"
This reverts commit 7b894df433.
2026-07-25 05:10:48 +00:00
Vitor PamplonaandGitHub c64d4eacd2 Merge pull request #3711 from davotoula/fix/flaky-large-cache-addressable-test
Fix GC-dependent flaky test in LargeCacheAddressableFilterTest
2026-07-25 00:47:54 -04:00
Claude 7b894df433 refactor: derive relay-group row preview from note cache, not lastNote
Align the discovery row's last-message preview with the pattern Concord's
list previews use: compute the newest chat message from the channel's note
cache reactively (keyed on the notes flow) instead of reading
Channel.lastNote. For relay groups the two are equivalent today — kind-11
threads live in a separate collection and kind-1111 comments aren't
attached to the group's notes — so this is a consistency/robustness change
that keeps the preview, its timestamp, and the message count all derived
from the same source, not a behavioral fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 04:44:36 +00:00
Vitor PamplonaandGitHub 9ffac69718 Merge pull request #3713 from vitorpamplona/claude/update-dependencies-kt62lx
Upgrade genaiPrompt to 1.0.0-beta4
2026-07-25 00:27:05 -04:00
Vitor PamplonaandGitHub 0cfbaf73ff Merge pull request #3712 from vitorpamplona/claude/nip29-relay-nav-behavior-tgddmf
Support RelayGroupChannelListScreen as bottom-nav tab
2026-07-25 00:26:35 -04:00
Claude 35914cf241 fix(bottom-bar): make a pinned NIP-29 relay a proper bottom-nav tab
A NIP-29 relay pinned to the bottom bar navigates to Route.RelayGroupServer
(RelayGroupChannelListScreen), but that screen always drew a back arrow and
never rendered a bottom bar — so tapping the pinned relay icon dropped the
bottom nav and showed a back arrow, unlike every other bottom-nav root.

Mirror the norm the analog Concord server screen already follows: read
nav.canPop() once, show the back arrow only when pushed (drawer / another
screen), and add an AppBottomBar keyed to the relay's own route. AppBottomBar
hides itself on a bottom-nav root, so the relay now behaves both ways — a
bottom-nav tab (bar visible, no arrow) when tapped from the bar, and a pushed
detail (arrow, no bar) when opened from the drawer or elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU7StQsshfjcaLDXTBtnB2
2026-07-25 04:24:16 +00:00
Claude 107dfeb1a8 feat: show last-activity time on relay-group rows; correct preview semantics
Add a compact "2h"/"3d" last-activity timestamp to each discovery row's
name line, so the preview reads as recent activity with a clear recency
signal rather than an implied live feed.

On the discovery screen most groups are ones you haven't joined, and the
app's always-on live chat tail is joined-groups-only — a non-member isn't
streamed a group's new messages — so the last-message line is a snapshot
of recent public activity that fills in and refreshes as the row loads,
not a real-time ticker. Reword the code comment to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 04:15:42 +00:00
Claude 364d0d1fde chore(deps): bump ML Kit genai-prompt to 1.0.0-beta4
Update the on-device GenAI prompt dependency (play flavor only) from
1.0.0-beta3 to 1.0.0-beta4, the latest in-track release.

All other catalog entries are already at their latest stable versions.
appfunctions could not move to alpha10 because appfunctions-service
only publishes up to alpha09 and the three artifacts share one version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZJ8ekaDDihWWhcJ6X2Fjz
2026-07-25 04:15:31 +00:00
davotoula f662b131c8 Code review:
- make the note list the fixture's source of truth
- trim the GC regression test
2026-07-25 06:10:08 +02:00
Claude 0611c3866d fix: make relay-group message count a neutral stat, not a violet pill
The message-activity badge used the accent (violet) color, which reads as
an unread indicator elsewhere in the app. Render it as a muted
onSurfaceVariant chat-icon + count instead, so it clearly signals
activity volume rather than unread messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAFP7qqYNxqRpxtofGWYsq
2026-07-25 03:54:28 +00:00
davotoula 94c50946e4 test(cache): add GC-forcing regression for LargeCacheAddressableFilterTest
fix(cache): keep LargeCacheAddressableFilterTest mocks strongly reachable

LargeSoftCache stores values as WeakReferences, so the cache alone does
not keep the mock AddressableNotes alive. Hold each note in a companion
strongRefs list for the lifetime of the test class, so a GC between
class-load and the read can no longer clear them.
2026-07-25 05:46:12 +02:00
Claude b5d64f6527 Merge remote-tracking branch 'origin/main' into claude/relay-groups-card-design-7i9qak 2026-07-25 03:29:38 +00:00
Vitor PamplonaandGitHub 8c046df330 Merge pull request #3697 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-24 23:28:55 -04:00
Vitor PamplonaandGitHub 52d5bc93c9 Merge pull request #3709 from vitorpamplona/claude/nip01-multirelaypool-test-failure-dtl9qp
Fix race condition in NIP-01 compliance test with concurrent relays
2026-07-24 23:28:36 -04:00
Vitor PamplonaandGitHub 5244a4a643 Merge pull request #3707 from vitorpamplona/claude/message-screen-channel-rendering-f8zkvd
Extract chat room mark-as-read logic into reusable function
2026-07-24 23:28:09 -04:00
Vitor PamplonaandGitHub 90dc9a874e Merge pull request #3685 from vitorpamplona/claude/nip-2421-pr-review-6znvdd
Add NIP-XX BOLT12 zap support with validation and UI integration
2026-07-24 23:26:28 -04:00
vitorpamplonaandgithub-actions[bot] 72aac98ff1 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-25 03:01:55 +00:00
Claude 9caf330879 fix(bolt12): don't throw on an oversized tu64; cover proof_note path
Audit of the compressed-proof work found one real defect and one coverage gap.

Defect: a hostile BOLT12 proof/offer can carry a 9+ byte `invoice_amount`
(or any tu64 field) that parses as a valid TLV. `TlvStream.tu64` then called
the strict `Bolt12Values.tu64`, which throws `require(size <= 8)`. On the
`amy bolt12 verify` path (`Bolt12ZapActions.validate`, no surrounding catch)
that surfaced as an uncaught exception and abnormal exit instead of a clean
`Invalid`; the Android ingest path was already contained by LocalCache's broad
catch. Make the nullable stream accessor `TlvStream.tu64` return null for an
over-8-byte value so every amount read (invoice_amount, invreq_amount, offer
amount) degrades to a clean rejection. Regression-tested at the codec level.

Coverage: the writer's `proof_note` (1005) branch and the `with_note` vector's
note were never exercised. Add a `Bolt12PayerProof.proofNote()` reader and
thread the vector's note through the writer round-trip so 1005 is asserted.

The forged-proof, DoS, and reconstruction-accounting paths were reviewed and
found sound (the reconstructed root is only ever a BIP-340 message; the NIP
offer-binding gate still pins invoice_node_id to the offer's issuer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 03:00:51 +00:00
Vitor PamplonaandGitHub 164e9975d7 Merge pull request #3708 from vitorpamplona/claude/blossom-mirror-api-support-q2h9t1
Add mirrorOrUpload fallback when Blossom /mirror endpoint unsupported
2026-07-24 22:59:01 -04:00
Claude cd221d5de1 feat: cap Messages room-label chips at ~half the row, unify Concord chip color
The type/label chips that sit beside a room name on the Messages screen — the
NIP-28 "Public Chat" pill (HeaderPill), the NIP-29 relay-host chip
(RelayNameChip), and the Concord community chip (ConcordCommunityPill) — could
grow with a long relay URL or community name and crowd the room name out.

Cap each at ChatLabelMaxWidth (140.dp, ~half a phone row) via widthIn(max); the
room name stays weighted so it keeps whatever the capped chip doesn't take, and
each chip's label truncates with a middle ellipsis (TextOverflow.MiddleEllipsis)
so the informative head and tail both survive. RelayNameChip switches from a
plain end ellipsis; ConcordCommunityPill drops its char-count truncation
(maxChars) for width-based truncation.

Also give the Concord chip the NIP-29 chip's highlighted look — secondaryContainer
background / onSecondaryContainer content (a gray on the dark theme) — so both
"which server/community does this room belong to" chips read the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
2026-07-25 02:57:56 +00:00
Claude 9bd339cba3 feat(blossom): fall back to upload when a sync/import target lacks /mirror
The File Sync / Import flow (and the mirror-on-upload fan-out) copy blobs
across the user's Blossom servers with BUD-04 `PUT /mirror`, but not every
server implements that endpoint. Blossom has no capability-discovery
mechanism, so a target without /mirror just answered 404/405/501 and the
whole copy was silently counted as failed.

Detect the "endpoint absent" statuses (404/405/501) as a typed
BlossomMirrorUnsupportedException — distinct from a mirror the server
understood but rejected (400/403/413/…) — and add BlossomClient.mirrorOrUpload,
which falls back to downloading the blob and re-uploading it (PUT /upload)
when mirror is unsupported. The downloaded bytes are verified against the
expected sha256 before re-upload, since a Blossom server is untrusted and
could substitute content, and the same t=upload auth is reused.

Wire every mirror path through mirrorOrUpload: the app-level
BlossomMirrorQueue (sync-all + import sweep), the blob manager's per-blob
mirror (including the paid-mirror retry), and UploadOrchestrator's
mirror-on-upload. Task now carries the descriptor content-type so the
fallback upload preserves the MIME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
2026-07-25 02:47:02 +00:00
Claude d32da653d3 fix(geode): make multi-relay compliance test thread-safe
multiRelayPoolReturnsContentFromEachRelay flaked with
"expected:<from-b> but was:<null>": the SubscriptionListener wrote the
per-relay results into a plain HashMap/HashSet, but each relay delivers
its EVENT/EOSE on its own InProcessWebSocket scope (Dispatchers.Default)
and PoolRequests dispatches the listener callbacks outside any lock. Two
relays therefore call `received[relay] = ...` concurrently, and a
HashMap.put racing a rehash can drop an entry, leaving a relay's value
null and failing the assertion.

Use ConcurrentHashMap and ConcurrentHashMap.newKeySet() for the shared
collections. Reproduced within 7 runs before the fix; 80 stress runs
clean after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HeAjLDNBvGPjb5bfViU3ad
2026-07-25 02:42:36 +00:00
Claude 7535d791f3 feat(bolt12): verify compressed payer proofs via merkle reconstruction
Real BOLT12 wallets emit selective-disclosure payer proofs: `invreq_metadata`
is always withheld and other invoice fields may be elided for privacy, with
`proof_omitted_tlvs` / `proof_missing_hashes` / `proof_leaf_hashes` carrying
enough to rebuild the invoice signature's merkle root. The verifier previously
reported these as unsupported (cryptoVerified = false), so a zap paid through a
real wallet never counted locally.

Implement the lightning/bolts#1346 reader:

- Bolt12Merkle.reconstructRoot rebuilds the invoice root from the disclosed
  LnLeaf hashes + supplied nonce leaves (proof_leaf_hashes) + omitted-field
  markers + missing subtree hashes (consumed post-order DFS, smallest-to-largest).
  Add emitMissingHashes as the writer dual, unify both on one tree builder.
- Fix two latent interop bugs the vectors exposed: the nonce leaf hashes the
  record's type bytes (not the full encoded TLV), and the payer proof signs
  under fieldname `proof_signature` (not `signature`).
- Bolt12PayerProof gains marker/leaf/missing accessors and the invoice-field
  range predicate; the verifier reconstructs on every proof (type 0 is always
  the implied first omitted leaf) and drops the Unsupported result.
- Add Bolt12ProofBuilder to mint spec-compliant proofs (tests + future interop),
  and rewire Bolt12ProofFixture onto it.

Validated byte-for-byte against the draft's own conformance suite
(bolt12/payer-proof-test.json): all 5 valid vectors verify, all 23 invalid are
rejected, and the writer reproduces every vector's compression fields exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SpgpWLKzgD7vS9Fs4CXTR3
2026-07-25 02:35:16 +00:00
Claude 039beb0684 fix: mark all room types read in Messages "mark as read"
markAllChatNotesAsRead only enumerated public chats (IsInPublicChatChannel)
and DMs (ChatroomKeyable), so every newer room type fell through the when()
with no branch: NIP-29 relay groups, Concord communities, Marmot groups,
geohash chat, and ephemeral relay chat. Their unread dots on the Messages
screen could only be cleared by opening each room — "mark all as read" left
them lit. The collapsed per-server rows (RelayGroupServerRoomNote,
ConcordServerRoomNote) were skipped too.

Extract markRoomNoteAsRead(account, note), mirroring ChatroomEntry's type
dispatch so each row's last-read route is resolved the same way its unread
dot reads it: synthetic grouped rows first (fanning out to every joined
group on the relay / every channel in the community), then gatherer-attached
channels (Marmot, NIP-29, Concord, geohash), then the h-tag group fallback,
then the raw event type (public chat incl. ChannelCreateEvent, ephemeral,
DM, and drafts wrapping those). markAllChatNotesAsRead now just maps the
visible notes through it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
2026-07-25 02:29:34 +00:00
Claude 4fe60162e1 feat: smaller subtitle font in Messages screen rows
The message-preview second line on every Messages-screen row (channels,
groups, and DMs) rendered at the ambient bodyLarge (16sp), matching the
bold title above it. Drop it to bodyMedium (14sp) so the title and the
muted preview read as two tiers instead of one block of same-size text.

Covers both renderers all rows funnel through: ChannelName (public
chats, ephemeral/geohash chats, Marmot/NIP-29 groups, Concord) and
LastMessagePreview (NIP-17/NIP-04 DMs), including the
"event not found" fallback line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KQ9Cz2QjLMvemzVyJS1f5V
2026-07-25 02:20:09 +00:00
Vitor PamplonaandGitHub cdc6efd05f Merge pull request #3705 from vitorpamplona/fix/concord-control-plane-starvation
fix(concord): isolate the Control Plane sub so channels don't starve
2026-07-24 21:57:50 -04:00
Vitor PamplonaandGitHub bd1a6dbf2d Merge pull request #3706 from vitorpamplona/claude/bottom-nav-relay-community-nswtrf
Add pinnable relay servers and Concord channels to bottom bar
2026-07-24 21:57:11 -04:00
Vitor PamplonaandClaude Opus 4.8 844b0e9803 fix(concord): isolate the Control Plane sub so channels don't starve
A Concord community pinned to the bottom bar folded only a fraction of its
channels — Soapbox showed 1 of 12. The live subscription collapsed a
community's Control + Guestbook + rekey + every channel plane into ONE
kind-1059 filter per relay, and the channel list is folded from the Control
Plane. On an AUTH-gated relay that caps a REQ per filter (measured ~100
events/filter on relay.dreamith.to), the chatty Guestbook plane crowded the
channel-defining control editions out of the cap, so only a fraction of the
channels folded. On the strict relay.ditto.pub the collapsed multi-author
filter is refused wholesale until every author is authenticated.

- Split the Control Plane into its OWN filter, apart from the Guestbook /
  rekey / channel planes (ConcordSubscriptionPlanner.controlIsolatedFilters),
  so it gets an isolated per-filter budget. Both filters still ride the same
  per-relay REQ (no extra socket).
- Add a COMPLETE-mode Control-Plane sweep (Account.syncConcordControlPlanes):
  re-fetch the whole plane with no `since`, paging past the per-filter cap via
  fetchAllPagesFromPool, so a forward cursor can never hide an edition and the
  cap can never truncate the fold. Mounted account-wide, fired on load +
  membership/held-epoch change + relay reconnect (not a wall-clock poll — the
  persistent live subscription keeps a connected relay complete).

Mirrors Armada's plane-sweep design (one filter per plane scope, COMPLETE-mode
control). Verified on-device: Soapbox now folds all 12 channels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 21:47:11 -04:00