Commit Graph
1076 Commits
Author SHA1 Message Date
Claude de2e71dad0 fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk
@e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/
relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing
bytes at a known version, and its extension/group_image.rs fully implements avatar
encryption. The previous "canonical raw-key + media_type" approach both (a) added a
trailing media_type field that mdk rejects — breaking the whole group for whitenoise
members — and (b) used a key scheme mdk can't decrypt.

Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte:
- image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's
  mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty
  salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed.
- ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext).
- Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk.
- Remove media_type from the wire entirely (and from the model/cipher/uploader), so a
  v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext
  MIME isn't stored; the display path lets Coil sniff the format.
- Derive the Blossom upload keypair from image_upload_key instead of storing a raw key.

Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a
v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD
scheme so future drift from mdk is caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
2026-07-15 00:32:32 +00:00
Claude 339d7c10e7 feat: Marmot group icons — canonical encryption, feed display, metadata editing
Add first-class support for Marmot (MLS-over-Nostr) group avatars.

Protocol (quartz):
- Implement the canonical `marmot-group-image-v1` scheme: raw ChaCha20-Poly1305
  key + 12-byte nonce, AAD = "marmot-group-image-v1" || 0x00 || media_type,
  image_hash = SHA-256(ciphertext). MarmotGroupImageEncryption emits canonical
  and decrypts both canonical and the deprecated MIP-01 HKDF-seed scheme.
- Add the `image_media_type` field to MarmotGroupData as a trailing TLS field
  (older readers ignore it; disappearing_message_secs stays positionally
  unambiguous). Add withImage/withoutImage helpers.
- MarmotGroupImageCipher (NostrCipher) drives both encrypted upload and
  transparent decrypt-on-download.

Model/manager (commons):
- MarmotGroupChatroom exposes an `image` StateFlow; MarmotManager.syncMetadataTo
  populates it from the group metadata.

Android:
- Show the decrypted group icon in the Messages feed; when a group has no image,
  fall back to the NIP-11 icon of one of its relays (fetched on cache miss).
- Group metadata editing gains an icon picker (add/change/remove) in both the
  create and edit screens; create also gains a description field. Icons are
  encrypted and uploaded to Blossom via UploadOrchestrator, signed with a fresh
  per-image keypair stored as image_upload_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
2026-07-14 23:17:34 +00:00
Vitor PamplonaandGitHub 8fc841e50e Merge pull request #3559 from vitorpamplona/claude/notecompose-pow-pill-style-vr6hbt
Unify note-header markers into a pill / quiet-mark design system
2026-07-14 11:19:34 -04:00
Claude 14d28dddad Merge remote-tracking branch 'origin/main' into claude/notecompose-pow-pill-style-vr6hbt
# Conflicts:
#	commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf
2026-07-14 15:00:16 +00:00
Claude 6b6f0fd2ae feat: cap every screen to the reading-column width on wide panes
Capping only the feeds' contentPadding left top bars, list screens,
bookmarks and settings stretched across the whole center pane. Move the
cap up a level: every NavHost destination is wrapped in
CappedScreenContent (600dp, centered) through the shared route builders
in NavigationEffects, so each screen's entire surface — top bar, tabs,
content — shares one reading column, on all ~200 destinations at once.

Opt-outs at registration: Route.Message keeps the full pane for its
two-pane list/conversation split, and Browser/WebApp/NostrApp stay
full-pane so the warm EmbeddedTabLayer surfaces keep lining up.

This supersedes the LocalFeedSidePadding-based capping on Android: the
shell no longer provides side padding (CenterPane is a plain Box again)
and the now-dead overrides in MessagesTwoPane and NotificationSidePanel
are removed. The commons local stays, documented as the padding-based
alternative for hosts like a desktop reading column where gutters
should still scroll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude c72024578e fix: harden the large-screen shell against runtime window-size changes
Fixes from an adversarially verified audit of the large-screen commit.
The two most serious bugs shared a root cause: the shell was correct at
any fixed size but mishandled the size CHANGING mid-session, which
foldables and multi-window make routine (MainActivity handles those
configChanges without recreation).

Bug fixes:
- Hoist the shell content into movableContentOf so crossing a layout
  tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between
  shells instead of disposing it — screen state (drafts, pager tabs,
  expanded states, warm embedded tabs) now survives.
- DisappearingScaffold snaps bars back to visible when hiding gets
  disabled, so chrome scrolled away before a resize is no longer
  stranded off-screen with no reset path.
- ProfileScreen keeps WindowInsets.navigationBars instead of zeroing
  all content insets; with the bottom bar gone (large screens, and
  pushed entries on phones) content no longer underlaps the system bar.
- New TabReselectCoordinator: AppBottomBar registers each screen's
  re-tap handler even when the bar renders nothing, and the rail routes
  selected-item taps through it — restoring tap-current-tab-scrolls-to-
  top on the rail tier with the screens' existing logic.
- NotificationSidePanel now reuses the screen's SingleNotificationsBody
  (parameterized by scroll-state key), which restores WatchScrollToTop —
  previously the panel stranded scrolltoTopPending=true on the shared
  feed state, suppressing later send-to-top requests — and the inbox-
  relay warning header; it also honors split notifications by showing
  the Following feed when that setting is on.
- Entering the permanent-drawer tier snaps a stale Open drawerState to
  Closed, so returning to a modal tier no longer pops the drawer
  uninvited.
- MessagesTwoPane keys its TwoPane strategy on the width size class so
  the split fraction updates when the pane crosses 840dp in place.
- The drawer status editor calls onDone() after send/delete, so it can
  collapse back to the read-only bar in the docked drawer (and no
  longer waits for a drawer close in the modal one).
- The landscape auto-close drawer effect's inverted condition
  (close-only-when-already-closed, a pre-existing no-op) now closes an
  open drawer as intended.

Structure and performance:
- INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer()
  no-ops while docked, and consumers stop inferring from a DrawerState
  that never transitions.
- zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked
  check so call sites can't forget it; TopBarNavigationIcon centralizes
  the back-arrow/avatar-or-nothing leading slot.
- The rail reuses AppBottomBar's entry icons (NotifiableIcon,
  FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating
  them.
- MessagesScreen derives its pane size class via
  WindowSizeClass.calculateFromSize instead of restating the 600/840
  breakpoints.
- rememberFeedContentPadding folds the scaffold, baseline, and side
  paddings into one remember slot; the shell quantizes the feed side
  padding to 8dp steps so continuous resizes don't invalidate every
  feed per pixel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:51:13 +00:00
Claude 8a34ae692f feat: adapt the app shell to large screens
Three layout tiers driven by the window width size class, published once
through LocalScreenLayout (ScreenLayout.kt):

- Compact (phones): unchanged — bottom bar + modal drawer.
- Medium (portrait tablets, unfolded foldables): the bottom bar is replaced
  by a left NavigationRail built from the same user-configured
  BottomBarEntry list (customization, pinned favorites and new-item dots
  carry over); the drawer stays modal behind the rail's avatar button.
- Expanded (landscape tablets, desktop windows): the drawer is permanently
  docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a
  docked notification panel renders the notifications card feed on the
  right, sharing last-read marking with the full screen. The panel hides
  while the Notifications screen itself is open.

Large screens also pin the chrome: DisappearingScaffold stops hiding the
top/bottom bars on scroll (and stops toggling the OS status bar), and
AppBottomBar renders nothing everywhere.

Feed content width is capped at 600dp inside wide center panes:
the shell measures the center pane and provides
(paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which
rememberFeedContentPadding merges into every feed's contentPadding — the
scroll surface stays full-width so pull-to-refresh and edge scrolling keep
working. Panes that manage their own width (Messages two-pane, the
notification panel) override it back to 0.

Screen sweep: Messages now picks single/two-pane from its actual pane
width instead of the window size class; the Home/Messages pagers only
attach the drawer edge-swipe when a modal drawer exists; top-bar avatar
drawer-openers hide on large screens; FABs keep their bottom spacing
without the bar; the status editor in the drawer no longer cancels editing
when the drawer is permanent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7
2026-07-14 14:50:29 +00:00
Claude 500de62841 fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger
Tapping the pending OTS pill now shows a toast explaining that the
attestation is waiting to be stamped into the Bitcoin blockchain (new
ots_info_pending_description). The OpenTimestamps glyph gets a
near-full-em content box — its fine outline read much lighter than
Material's solid shapes at the standard 80..880 bounds — and HeaderPill
icons go from 11dp to 13dp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 14:15:56 +00:00
Claude 568aa9b005 fix(ui): use middle ellipsis in HeaderPill labels
Truncated pill labels (capped city names, relay hosts) keep their
distinguishing endings, matching the codebase convention for URLs and
relay links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:49:08 +00:00
Claude 811d9cf802 feat(ui): icon-only fork mark, pencil edit mark, tighter expiration and location pills
- The fork marker drops "Forked from <name>" for a bare fork-right icon
  (new MaterialSymbols.ForkRight, U+EBAC); tapping still opens the
  original version. Font regenerated via subset.sh, which also re-baked
  the custom OpenTimestamps glyph, proving the custom-glyph pipeline
  survives regeneration.
- The edited mark becomes a pencil: bare pencil for the latest edit,
  pencil + "#2"/"original" only while cycling versions on tap.
- The expiration pill clamps beyond one year to "1y+" instead of
  switching to a full date.
- The location pill caps at 110dp and ellipsizes, so unbounded city
  names cannot squeeze the author's name out of the row.
- Remove the now-unused existed_since string from all 35 locale files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:46:12 +00:00
Claude 170bc7c121 fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted
Quiet marks go back to the row's regular text size in bold with 16dp
icons; the hashtag/community soft links lose their 12sp override too
(the smaller tier read as too small). A new TimeAgoStyle.DottedTight
renders "• 5m" without the leading space for rows whose spacedBy
already provides the gap, removing the double space before the
timestamp. The OTS pending pill shrinks to the stamp icon plus an
ellipsis (the words move to the content description). The Boosted mark
is removed entirely — from the Android header, the commons component,
and the desktop feed — since the repost context is already visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:30:38 +00:00
Claude 41ef1d89b3 feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill
Bake the official OpenTimestamps stamp logo (traced from
opentimestamps/logo vector.svg; monochrome outline, tinted at render
time like every other glyph) into the Material Symbols subset font at
U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts
the traced SVGs in custom/ into TrueType glyphs and is invoked by
subset.sh after pyftsubset, so font regenerations keep them.

With the logo identifying the pill, drop the verbose "OTS:" prefix:
the pill now reads icon + "2y" (or icon + "Pending", new
R.string.pending).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 12:59:30 +00:00
Claude 86da29fa78 fix(ui): mute the header pill wash, restore dotted timestamps, tighten menu gap
- HeaderPill's secondary-container background pulled too much attention;
  it now uses a faint onSurface wash (7%) with placeholderText content,
  so pills read as tappable metadata without competing with the note.
- Note-header timestamps go back to the original dotted format at the
  default size; the TimeAgo style/fontSize params are reverted.
- The timestamp + more-options pair renders unspaced again (the dot and
  the button's icon inset provide the separation), fixing the oversized
  gap the row-level spacedBy introduced before the 3-dot menu.
- The header preview now consumes its fabricated events as
  already-verified (they cannot pass id/sig checks, which left every row
  bare), gives the repost a parseable inner event, and fetches the draft
  through its AddressableNote (draft wraps are addressable events).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 01:26:33 +00:00
Vitor PamplonaandGitHub 107777369f Merge pull request #3548 from vitorpamplona/claude/user-nicknames-contactcard-mglbz8
Nickname users via NIP-85 contact cards (encrypted petname + private note, custom emojis)
2026-07-13 20:16:09 -04:00
Claude b76b37744e feat: nickname card on the user profile, above the real display name
The profile header no longer replaces the big display name with the petname.
Instead, when the account nicknamed the user (or kept a private note about
them), an outlined card renders above it — petname, divider, private summary —
with the standard Lock private marker in its top-right corner, since both
fields live NIP-44 encrypted in the account's contact card. Tapping the card
opens the shared nickname editor. The profile's own display name stays fully
visible underneath. Feeds, chats and mentions keep rendering the petname
instead of the display name.

To carry the summary into the UI, the commons PetName holder generalizes to
Nickname(petName?, summary?, tags), built when either field exists — so a
note-only card (no petname) now shows on the profile too, while the name
override everywhere else keys strictly off petName.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 23:33:05 +00:00
Claude a6da2ee69d fix: record contact-card EOSEs from the d-tag so card syncs are incremental
UserCardsSubAssembler.newEose read the filter's p-tags to stamp each target
user's card EOSE, but kind:30382 filters address targets in the d-tag — so
no per-user EOSE was ever recorded, groupByRelayPresence always classified
users as never-checked, and every filter update re-downloaded the visible
users' cards with since = null. Read the d-tag instead, and use DTag.TAG_NAME
on both the filter builder and the EOSE reader so the two keys cannot drift
apart again. Pre-existing on main; surfaced while verifying that card syncs
are incremental from the account's outbox relays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:52:29 +00:00
Claude e8ecf429a2 refactor: move nickname policy, emoji autocomplete, filters and dialog to commons
Generalizes the remaining platform-bound pieces of the nickname feature so
the desktop app can adopt it:

- ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render
  policy (petname over profile name over npub); the Android observeUserName
  is now a thin wrapper around it
- EmojiSuggestionState.autocompleteInto completes the word under the cursor
  and closes the list — replaces the insert+reset pair copy-pasted in the 8
  composer ViewModels and the nickname dialog
- the kind:30382 filter builders move to commons relayClient/assemblers
  (cards about targets from trusted accounts, and the account's own cards by
  author), shared by the user watcher and the login subscription
- ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil
  and commons string resources
- EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the
  ContactCardsState and an onSave callback, so each front end only wires its
  own publish path and menu entry; dialog strings move to commons resources

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 21:20:36 +00:00
Claude 1d385cae0f refactor(ui): move HeaderPill and QuietMark to commons/ui/note
The two note-header marker primitives are pure Compose with no Android
dependencies, so they belong in the shared layer where the desktop app
(and future iOS) can render the same design system. QuietMark's gray
comes from a new commons ColorScheme.placeholderText extension
(onSurface at 42%, matching the Android theme's value).

The thin app-side wrappers (BoostedMark, DisplayPoW, DisplayDraft,
DisplayOts, DisplayLocation, DisplayReward, ...) stay in amethyst: they
bind the primitives to Android string resources (with existing
translations that commons' compose-resources catalog does not have) and
to app state (LocalCache loaders, AccountViewModel, navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-13 16:04:36 +00:00
Claude ab4b0b984d refactor: audit nickname code — dedupe emoji helpers into commons
Review pass over the nickname/contact-card feature for reuse, simplicity and
performance:

- EmojiSuggestionState moves to commons next to EmojiPackState and now takes
  the EmojiPackState directly instead of the whole Android Account, so the
  desktop app can reuse the :shortcode: autocomplete
- the findEmoji helper that was copy-pasted in 8 composer ViewModels is gone;
  everyone calls the shared EmojiPackState.findEmojiTags, which now resolves
  codes through a map lookup instead of a linear scan per code
- ContactCardsState owns the emoji resolution for nickname saves (Account
  just publishes), takes EmojiPackState, and drops its unused scope param
- new synchronous cachedPetName path (decryption-cache read, no crypto) seeds
  observeUserName/observeUserPetName initial values, removing the flash of
  the real name before the flow's first emission
- nickname dialog prefill no longer clobbers text typed while a slow external
  signer decrypts the existing card

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:40:38 +00:00
Claude 184f0bfde7 refactor: align ContactCardEvent with the nip88Polls class structure
Replicates the PollEvent layout: all tag parsing moves to TagArray extensions
in TagArrayExt.kt with the event accessors delegating to them, builder
extensions use addUnique for single-instance tags, and construction goes
through template-returning builders instead of methods that sign internally.

- build() returns an EventTemplate<ContactCardEvent> (the signer is only used
  to NIP-44 encrypt the private tags, matching TrustProviderListEvent);
  create() remains as the signer.sign(build(...)) convenience and now takes
  the emoji list directly
- updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign
  it (ContactCardsState and tests updated)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 12:28:23 +00:00
Claude b282ac32af feat: turn Notify chips into bell mute-toggles listing all thread members
The composer's Notify row previously showed only the parent's p tags and
its author, each with an x that removed the user for good. It now lists
every thread member (authors along the reply chain plus the parent's
mentions), and each chip carries a bell instead: tapping it mutes the
notification for that user but keeps the chip — faded and with a
bell-off icon so the state is obvious — making it one tap to add them
back. Muted members are dropped from the outgoing event's p tags (and
from a private note's receivers), and drafts round-trip the muted state
by re-deriving thread members whose p tag the draft dropped.

The NIP-22 comment composer gets the same chip semantics; there the
mute is honored for the optional zap-sender tag, while the structural
root/reply scope tags stay as NIP-22 requires.

Adds the notifications_off glyph to the Material Symbols subset font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eK8mGPcuKYXNyhKVJ8Wg7
2026-07-13 08:48:02 +00:00
Claude ebeecd1ee7 feat: custom emojis in contact card petnames
Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog
autocompletes from the account's emoji packs, and the emoji mappings for any
shortcode used are embedded in the card's NIP-44 encrypted content next to the
petname — so even the emoji set stays private. Renderers resolve the petname's
shortcodes against the card's decrypted tags instead of the profile's metadata
tags.

- quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale
  and keeps it out of the public tags; round-trip test added
- commons: PetName(name, tags) holder with content equality, decryption cache
  returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves
  :codes: against the selected packs
- amethyst: Account embeds resolved emoji tags on save; all petname render
  sites pass the card tags to the WithEmoji composables; nickname dialog gets
  the : emoji autocomplete via EmojiSuggestionState

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:39:59 +00:00
Claude 8ee90907ca feat: nickname users via NIP-85 contact cards signed by the account key
Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761,
reusing the kind:30382 contact card already used for WoT scores — a card only
acts as a nickname when signed by the account's main key. Petname and summary
are always stored in the card's NIP-44 encrypted content.

quartz:
- ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted
  private tags, strips stray public copies, preserves every other tag
- TagArray.petName()/summary() parsers for decrypted tag lists + tests

commons:
- ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards
- ContactCardsState: account-scoped access to the account's own cards,
  petname flow per target user, create/update entry point

amethyst:
- Account.updateContactCardPetName publishes through the extended outbox
  relays (NIP-65 write + private outbox + local + broadcast) and the login
  subscription now downloads the account's own kind:30382s from its relays
- petname renders instead of the display name in usernames, profile header,
  chats and @mentions (observeUserPetName)
- Edit-nickname action + dialog on the profile actions menu

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
2026-07-13 01:26:27 +00:00
davotoula 0053edf2dd refactor: adopt androidx.core KTX helpers (Bitmap/Uri/SharedPreferences) 2026-07-11 17:00:40 +01:00
Vitor PamplonaandGitHub 8e38ea6607 Merge pull request #3533 from vitorpamplona/claude/amethyst-miner-performance-0rw1dn
Parallelize PoW mining with multi-worker nonce search
2026-07-11 09:54:01 -04:00
Claude 5683c74629 perf(amethyst): mine on half the device's cores in the Android app
Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel
searches (at least 1) instead of a single thread, so a post on an 8-core
phone mines ~4x faster while the other half of the cores stays free for
the UI, and the total CPU budget matches the old 2-job x 1-thread pool.

- PoWPolicy.minerWorkers(availableProcessors) is the single definition of
  the per-job worker budget.
- PoWPublishQueue gains minerThreads (used by its built-in miner and
  exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 +
  minerThreads = half the cores.
- PoWNostrSigner gains a workers param (default 1), covering reactions,
  reposts and reports via Account.miningSigner; the anonymous-post paths
  in the short-note and comment composers pass the same budget.
- Gift-wrap envelope mining stays single-threaded: the template
  conversion is a non-suspend hook inside the synchronous NIP-59 build.
- UI time estimates (settings picker, composer chip, broadcast banner)
  now benchmark at the queue's worker count via deviceHashesPerSecond(),
  and PoWEstimator caches one rate per worker count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 12:13:32 +00:00
davotoula cf95b07855 fix: document intentionally empty default no-op bodies 2026-07-11 11:12:20 +01:00
Claude ac98036a8e perf(pow): mine NIP-13 proof of work on all cores and drop per-hash allocations
The miner enumerates the nonce space deterministically (the random base is
overwritten before the first hash), so naively racing N copies of the search
duplicates the exact same candidate sequence N times. PoWMiner.mine() now
races workers over disjoint slices instead: each worker's nonce carries a
distinct fixed prefix while only the bytes after it are enumerated, making the
aggregate hash rate scale with cores (~3.8x on a 4-core box).

The hot loop also switches from sha256() to sha256Into() with a reused
32-byte buffer, so hashing no longer allocates per attempt.

amy wiring:
- `pow mine` and `post --pow` mine on all cores by default; `pow mine
  --threads N` overrides.
- `pow bench` measures the all-cores rate (what mining now uses, also the
  basis for expected_seconds) alongside a new hashes_per_second_single_core.
- PoWEstimator benchmarks with sha256Into to match the miner, and gains a
  workers overload that prices in cross-core contention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM
2026-07-11 04:04:28 +00:00
Vitor PamplonaandGitHub e60003063b Merge pull request #3527 from vitorpamplona/claude/nip13-pow-publishing-queue-1takp1
Add NIP-13 Proof-of-Work mining queue with persistence and UI
2026-07-10 21:48:03 -04:00
Claude 39ffb0ed4d feat(pow): move composer PoW button before Subject; switch icon to Manufacturing
The per-post PoW button on the New Post screen now sits before the
Subject/Title toggle instead of last in the options row.

Every PoW surface (composer button, note chip, mining banner, settings tile,
relay-info minimum-PoW row) switches from the Bolt icon to Manufacturing
(gear, U+E726): Bolt is the app's zap/lightning glyph everywhere else, so a
Bolt PoW badge next to the zap-split/zapraiser/invoice buttons read as a
payment feature. Subset font regenerated for the new codepoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 01:33:54 +00:00
Claude 6dd1e4d31d fix(pow): resolve audit findings — durability, coverage, perf and l10n
Durability & correctness:
- Keep the job entry + disk checkpoint alive until the publish continuation
  completes (was: dropped at mining-complete); failed publishes keep their
  checkpoint for restart retry and surface through a failures SharedFlow
- Move draft deletion into the publish continuations in every composer so a
  cancelled or process-killed mining job can't destroy the only copy of a post
- Persist gift-wrap mining: split NIP17Factory into createSeals (signer
  interaction, runs inline) + wrapSeal (pure CPU, runs on the queue), new
  REPLAY_WRAPS records restore pending DM wraps after process death
- Clamp synced NIP-78 difficulty (PoWPolicy.MAX_DIFFICULTY), require a sane
  target in PoWMiner, bound PoWRankEvaluator against short ids
- Reaction double-tap while mining now toggles (dedupeKey + cancelByKey)
  instead of publishing duplicates
- Restore checkpoints for every loaded account, not just the active one
- logOff purges the account's checkpoints and cancels its queued jobs
- Private notes: composer chip now gates on the gift-wrap kind and the
  per-post override reaches sendPrivateNote

Coverage:
- Public/live chat (kinds 42 + 1311) and voice replies (1244 + kind-1 audio
  replies) now route through the mining gate

Perf:
- FGS start() dedupes with a running flag; PendingIntents built once
- Banner 1 Hz clock only ticks while a job shows elapsed time
- PoWEstimator benchmark is single-flight behind a Mutex

UX / l10n:
- Post-mining failures toast with retry information
- Count strings converted to <plurals>; elapsed time via DateUtils; settings
  estimate uses localized units; shared powKindLabelRes replaces three
  duplicated kind→label maps; dead pow_chip_* strings removed
- CLI: pow mine lowercases the pubkey before mining (uppercase hex mined an
  id that never matches the signed event) and validates via quartz Hex

New queue tests: checkpoint lifetime, failure reporting, cancelByKey toggle,
per-owner cancellation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-11 00:31:05 +00:00
Vitor PamplonaandGitHub 57aaf69073 Merge pull request #3524 from vitorpamplona/claude/relay-icons-display-timing-7c3n7c
Fix relay attribution in gift-wrap chains and Marmot messages
2026-07-10 19:11:47 -04:00
Claude 3a96554c8d feat(relayauth): replace the policy list with Always / Never / Custom + toggles
Restructure the global auth control into a top-level mode — Always authenticate,
Never authenticate, or Custom — where Custom reveals independent per-situation
toggles instead of the confusing single sub-toggle:

- My relays and venues (own relays + joined/subscribed/favorited venues) — on
- Read posts from people I follow — on
- Message people I follow (DMs, replies, notifications) — on
- Message anyone / strangers — off by default (you're asked each time instead)

RelayAuthPolicy is now {ALWAYS, NEVER, CUSTOM}. The resolver takes a
RelayAuthCustomToggles plus split serves-facts (followed-read, followed-write,
stranger-write, own-relay, venue) and, under CUSTOM, allows if any enabled
category matches — else falls through to a prompt. There is deliberately no
"read strangers' posts" category, so that always prompts.

Account settings replace the single delivery flag with four persisted booleans
(default policy CUSTOM; no migration, unreleased). The contextual DM/notification
prompt button now switches to CUSTOM and enables both message toggles. Resolver
tests rewritten per-toggle, including that reading a stranger is never
auto-allowed. Old IF_IN_MY_LIST / TRUSTED_FOLLOWS policies and their strings are
removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 9ca1ba2f5c feat(relayauth): reframe follows trust as relationship vs. message delivery
Restructure the two TRUSTED_FOLLOWS controls so they read as independent ideas
instead of a confusing read/write sub-toggle:

- The "My relays and people I follow" policy now trusts a followed user as any
  counterparty — reading their posts AND reaching them (DM/notification) — plus
  your own relays and joined venues. Reading your follows is no longer gated.
- The sub-toggle is repurposed to "Also log in to deliver my messages": trust a
  relay to send DMs, replies or notifications to anyone you're talking to, even
  people you don't follow.

Resolver: replace servesFollowed{Write,Read}Counterparty with a single
servesFollowedCounterparty, add servesWriteCounterparty (an inbox of anyone
you're messaging), and gate the latter behind the new
messageDeliveryTrustEnabled input. Rename the account setting
relayAuthTrustFollowsForReads -> relayAuthTrustMessageDelivery (+ pref key;
no migration needed, unreleased). The contextual prompt button moves from
read-post prompts to DM/notification prompts and now enables message delivery.
Resolver tests updated for the new inputs, including that the delivery toggle
is write-only and never auto-allows reading a stranger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:13 +00:00
Claude 3526a14ebe feat(relayauth): venue-aware auth for public chats, communities & live streams
The purpose model was person-centric, so an auth-required NIP-28 channel,
NIP-72 community, or NIP-53 live stream broke: reads filter by #e/#a with
no authors, so nothing was attributable -> silent DENY (chat wouldn't
load); top-level posts (no p tags) also silently failed; replies were
mis-attributed as "notify" and trusted on the wrong signal.

Add venue purposes (POST_VENUE / READ_VENUE) carrying the venue id
(channel event-id, or community/live `kind:pubkey:dTag` address). The
deriver recognizes kind-42 channel posts (root `e`), community/live posts
and reads (`#a` 34550/30311), and channel reads (`#e`). Venues are trusted
under TRUSTED_FOLLOWS when you've joined them (publicChatList /
communityList) or their owner — the pubkey in the address, e.g. a live
stream's host — is someone you follow; trusted venues auto-auth for both
reading and posting.

Safety net: any active use we still can't attribute yields an OTHER
purpose so the relay is prompted about instead of failing silently.
Prompt copy gains venue-aware titles/consequences ("Post to this room?",
"If you don't, your message won't be posted."). Default policy already
TRUSTED_FOLLOWS, so joined venues just work. Unit-tested across resolver
and deriver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:11 +00:00
Claude c29374f089 feat(relayauth): surface failed sends + per-relay forget/last-used
Consume the new give-up signal: RelayPublishFailureToastSubscription
(hosted in LoggedInPage) listens for onEventGaveUp and toasts "couldn't
deliver to <relay>", so a dropped send is visible instead of silent.

Rationale polish in the auth settings screen: each relay card now shows
"Last used N ago" and a Forget button that clears both the ALLOW/DENY
override and the accumulated rationale for that relay. The store gains
clearRationale + allLastUsed (default-implemented on the interface) and
records a last-used timestamp on each grant; clearDecision/clearRationale
now prune the shared url key only when a relay has neither an override
nor rationale left, so a partial clear never orphans the reverse-lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude 70b23b6999 feat(relayauth): remember and show why each relay was granted
Extend RelayAuthPermissionStore with grant rationale (purpose ->
counterparty pubkeys), default no-op so other implementers are
unaffected; DataStoreRelayAuthPermissionStore persists it per relay,
merging counterparties across grants. AuthCoordinator records the
rationale via ledger.recordGrant whenever it authenticates (auto-allow,
override, or approved prompt). The relay-auth settings screen adds a
"Why you're logged in to these relays" section: per relay, purpose-
grouped rows ("Send your private message to:", "Download posts from:")
with each counterparty's avatar + name. Unit-tested: grouping, cross-
grant merge, and that counterparty-less purposes aren't recorded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:10 +00:00
Claude 4bd73596e3 feat(relayauth): add purpose-aware AUTH policy core + TRUSTED_FOLLOWS
Introduce the platform-neutral decision core for contextual NIP-42 auth
in commons/relayauth:
- AuthPurpose / AuthPurposeKind / RelayAuthContext describe *why* a relay
  wants auth (send DM, deliver notification, read outbox, own relay).
- RelayAuthVerdict adds an ASK outcome (runtime-only; never persisted,
  unlike the two-value RelayAuthDecision override).
- RelayAuthResolver is a pure, unit-tested precedence ladder: blocked
  list > per-relay override > policy > ASK-if-attributable-else-DENY.
- New TRUSTED_FOLLOWS policy: auto-auth for relays serving a followed
  counterparty on a write purpose (DMs/notifications), and — behind a
  read sub-toggle — read purposes; strangers fall through to ASK.

Wires the new enum value through the existing settings screen (new
option + strings, reusing the Group symbol) and the URL-only ledger path
(degrades to the my-list check until challenge context is plumbed).
Live prompt UI, grant-rationale persistence, and quartz challenge-context
plumbing are follow-up steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
Claude 1cffd8db18 fix: propagate relay acceptances into gift-wrap chains for chat relay icons
Chat rows render the inner rumor note of a NIP-17 message, but relay
attribution only landed there through narrow windows, so accepted relays
often never showed as icons:

- Duplicate deliveries were stranded on the wrap: a gift wrap re-delivered
  by a second relay hit the duplicate branch of consumeRegularEvent, which
  tagged the outer wrap note only and never re-processed the event. Extract
  the OK-path drilling (wrap -> seal -> rumor) into
  LocalCache.addRelayToNoteAndInners and call it from both the OK
  confirmation path (markAsSeen) and the duplicate EVENT path, replacing
  CacheClientConnector's private copy.

- Cross-thread visibility: Note.event, Note.relays, Note.flowSet and the
  innerEventId of GiftWrapEvent/SealedRumorEvent are written by decrypt/index
  coroutines and read lock-free on relay socket threads; a stale read parks
  an acceptance on the outer envelope permanently. Mark them @Volatile.

- Orphaned UI flows: RenderClosedRelayList/RenderAllRelayList and
  createMustShowExpandButtonFlows captured note.flow().relays.stateFlow once
  in remember/stateIn; MemoryTrimmingService.cleanObservers destroys the
  unobserved NoteFlowSet while the lifecycle is stopped, so resumed rows
  never saw another relay update. Wrap in a cold flow that re-resolves
  flow() on every collection start.

- Indexing latency: sent DMs waited for the ~1s newEventBundles batcher
  before the self-wrap was unwrapped and the message reached the chatroom,
  parking early OKs on the wrap. broadcastPrivately and
  sendNip04PrivateMessage now run the EventProcessor on the freshly
  consumed note immediately; the batched re-delivery is idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEiq1NMK3q12KGQ2yYhPEp
2026-07-10 22:29:41 +00:00
Claude 97fc801294 fix: NIP-13 compliance — refresh created_at at mining start, clean nonce tag
Findings from a full NIP-13 review:

- The spec recommends updating created_at while mining. Queue jobs now
  re-stamp the template to "now" when a worker picks them up (a post can
  wait behind other jobs, and a job restored after process death could be
  hours old); the restorer does the same. Scheduled posts are exempt —
  their future created_at is intentional. Anonymous posts re-stamp before
  mining against the throwaway key.
- PoWTag.assemble(nonce, null) serialized the literal string "null" as
  the third tag entry; a missing commitment now omits the entry entirely.
- New tests: PoWTagTest pins the NIP-13 example tag shape and the
  no-commitment round trip; a queue test asserts the created_at
  re-stamp at mining start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 22:22:04 +00:00
Vitor PamplonaandGitHub 4d16d6e38f Merge pull request #3522 from vitorpamplona/claude/richtext-trailing-whitespace-85eudl
Strip trailing whitespace from rich text parser output
2026-07-10 18:11:21 -04:00
Claude 24ea909221 fix: remove trailing whitespace and newlines when rendering RichText
Trailing spaces and newlines in a note's content produced empty trailing
paragraphs after the line split, each rendered as a blank FlowRow line
between the last visible word and the end of the component. Trim the
content's tail before segmenting, and return no paragraphs for
whitespace-only content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UZET7ig8MvR5vSCSxvcURi
2026-07-10 22:09:11 +00:00
Claude 96965305cf fix: use Brainstorm's real apple-touch-icon in browser suggestions
https://brainstorm.world/brainstorm.svg 404s (the SPA serves its HTML
not-found page with a 200), so the Discover row fell back to the globe
glyph. Point at the site's declared 180x180 apple-touch-icon.png instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EzQhmuFNULgiP31XqMRpJP
2026-07-10 21:53:49 +00:00
Claude 6ef1caef77 feat: polish the PoW surfaces — icon chip, animated banner, time estimate, note pill
- Composer chip: replaced the text button with a bolt icon carrying the
  difficulty as a small badge (primary-tinted when active, dimmed when
  off), matching the layered-icon language of the rest of the options
  row; the current choice is bolded in the override menu.
- Mining banner: pulsing bolt while the nonce search runs, per-job
  elapsed time driven by a 1 Hz clock ("Note • mining at 20 bits • 12s"),
  and proper close IconButtons instead of a text "×". PoWJobState now
  carries miningStartedAt for the elapsed display.
- Settings: a live "≈ 45s per post on this device" estimate under the
  difficulty picker, from a one-shot cached sha256 benchmark
  (PoWEstimator in commons) and the 2^d expected-attempts mean.
- Received notes: DisplayPoW's plain "PoW-24" text is now a compact
  secondaryContainer pill (bolt + bits) used by NoteCompose, thread view
  and chat messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:22:27 +00:00
Claude 35647d0a5c feat: persist the PoW queue and shield it with a shortService foreground service
Backgrounding the app no longer pauses or loses mining:

- commons: PoWPublishQueue checkpoints template jobs through a
  PoWJobPersistence hook (saved on enqueue, removed on finish/cancel,
  deduped by id so restore is idempotent) and fires onQueueActive on
  every enqueue. PoWReplay flattens the live continuation into a
  PersistedPoWJob record (broadcast / to-relays / schedule) that can be
  replayed headlessly. Opaque enqueueWork jobs (reactions, reposts, gift
  wraps) and anonymous posts stay in-memory on purpose — a throwaway key
  and its content must never touch disk.
- amethyst: PowJobStore (atomic-rename JSON file, single-lane writer,
  3-day staleness purge) and PowJobRestorer, which re-enqueues an
  account's checkpointed jobs on login and finishes them via the replay
  descriptor. Composers now pass the matching PoWReplay for the public,
  group-thread and scheduled paths.
- PowMiningForegroundService: a shortService-type FGS started on every
  enqueue (~3 min guaranteed budget, no special permission) that keeps
  the process out of the cached-apps freezer while mining. Stops itself
  when the queue drains; on onTimeout it exits cleanly since the jobs
  are checkpointed. The notification is a live progress card
  (NotificationCompat.ProgressStyle): one track segment per post filling
  as jobs finish, indeterminate for a single post, per-kind text, a
  cancel-all action, and Live Updates rendering on Android 16+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 21:08:51 +00:00
Claude 8988c98308 feat: add NIP-13 proof-of-work publishing with a fire-and-forget mining queue
Implements user-facing PoW publishing (#3317) on top of the existing
quartz miner:

- quartz: PoWMiner.run gains a cooperative isActive cancellation hook
  (checked every ~VALID_BYTES^2 hashes); PoWNostrSigner decorator mines
  kind-scoped templates pre-signature so it composes with every signer;
  GiftWrapEvent.create/NIP17Factory can mine the outer ephemeral-key
  wrap (never the seal/rumor); NostrSignerWithClientTag exposes
  prepareTags so mining runs over the final tag set.
- commons: PoWPublishQueue (FIFO, capped worker pool on
  Dispatchers.Default, per-job cancel, in-memory only — unmined posts
  are lost on process death, logged) and PoWPolicy (kind-group
  categories with a hardcoded NEVER list: auth, zap requests, NWC and
  bunker RPC, HTTP/Blossom auth, drafts, metadata and lists, OTS).
- amethyst: per-account synced settings (difficulty Off/16/20/24/28 or
  custom, per-category checklist) in Compose Settings; Post enqueues the
  template and returns immediately; reactions, reposts, reports, private
  notes, DMs and long-form route through the same shouldMine gate at
  their existing choke points; per-post PoW override chip in the
  composer options row; "Mining proof of work… (N in queue)" phase with
  per-job cancel in the broadcast banner.

Scheduled posts and anonymous posts mine against the correct key
(scheduled posts skip mining in v1); the client tag is applied to the
template before mining so signing never invalidates the nonce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADb3dez9jPk6QqyQ1rTx4V
2026-07-10 20:24:45 +00:00
Vitor PamplonaandGitHub 525d3ef189 Merge pull request #3514 from vitorpamplona/claude/armada-nip29-integration-lwqard
Relay Groups: NIP-29 relay-based group chat (Armada interop)
2026-07-10 13:03:48 -04:00
Claude ace7a7f476 fix(relay): enforce blocked relays centrally on every REQ/COUNT/publish
Relay targeting is fully distributed: every feed, loader, finder and
broadcast path builds its own relay set and hands it to the shared
INostrClient. Only the follow-outbox flows and the top-nav feed filters
subtracted the NIP-51 kind:10006 blocked list, so blocked relays still
leaked in through the event/thread loaders (FilterMissingEvents /
FilterMissingAddressables), the user-metadata finder
(pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot
fetch helpers, and the publish path (Account.computeRelayListToBroadcast)
— none of which consulted the blocked set.

Add BlockedRelayFilteringClient, a thin INostrClient decorator that
strips the active account's blocked relays from subscribe, count and
publish right before they reach the pool. Because the one-shot fetch
helpers route through subscribe/count, wrapping the client covers them
too. The blocked set is read per-call so account switches and list
edits apply with nothing to invalidate.

Wire it around the shared app client (blocked set from the logged-in
account) and around the per-account crawl client used by Event Sync and
Cashu discovery. Add commonTest coverage for the filtering, pass-through,
fully-blocked, and per-call-read behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf
2026-07-09 21:50:55 +00:00
Claude 2eb7ff2ef2 Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard
# Conflicts:
#	cli/tests/.gitignore
2026-07-09 21:48:04 +00:00
Claude a645688baf test+polish: verify NIP-29 placeholder gatherer; show "No messages yet"
Add a regression test proving RelayGroupChannel.placeholderNote() carries the
channel as a gatherer and is cached/stable — this is what lets the Messages row
renderer resolve the event-less placeholder back to the group (the prior fix).

Also give the empty-group placeholder row a visible "No messages yet" second
line instead of blank content, matching the Marmot-group row, so a just-joined
group with no messages reads clearly rather than looking like an empty item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
2026-07-09 21:21:59 +00:00