The onchain wallet's Taproot address is derived from the account's Nostr
pubkey, so anyone with the npub can see its balance and transaction
history on-chain. Surface that fact directly on the card with a tappable
"(i) Public" chip that opens a dialog explaining the privacy implication
and recommending private (non-Nostr) channels for funding and draining.
Each target now renders in a single row with the payment method and
address on the left and three trailing icon buttons: QR (opens a dialog
showing the raw address as a QR code), Copy (clipboard + toast), and
Pay (existing payto:// intent). Adds QrCode2 to MaterialSymbols.
https://claude.ai/code/session_01JtJuvSYMusKiDMWo7N8Aj8
srcDirs(vararg) on AGP 9 source-set Resources is deprecated; switch to
the directories MutableSet API. Preserves the original behavior of
including quartz/src/androidDeviceTest/resources in :benchmark's
androidTest java resources (verified bip39.vectors.json shows up in
processBenchmarkAndroidTestJavaRes output).
Converts the last four Groovy build scripts to Kotlin DSL so the entire
build is consistent with the rest of the modules (commons, quartz, etc.).
- settings.gradle -> settings.gradle.kts
- build.gradle (root) -> build.gradle.kts
- amethyst/build.gradle -> amethyst/build.gradle.kts
- benchmark/build.gradle -> benchmark/build.gradle.kts
Notes:
- Switched the per-subproject spotless format from groovyGradle to
kotlinGradle now that the only gradle scripts are .gradle.kts.
- benchmark module: moved targetSdk from defaultConfig to testOptions —
AGP 9 removed targetSdk from library defaultConfig.
- Reimplemented getCurrentBranch() with ProcessBuilder (Groovy's
String.execute() isn't available in Kotlin DSL).
Accounts whose reactionRowItems were persisted before ReactionRowAction.Pay
existed still load a 5-item list, so Pay never appears in Reaction Settings.
Add mergeWithDefaultReactionRowItems mirroring mergeWithDefaultVideoPlayerButtons
and apply it where reaction rows enter the StateFlow (constructor + updateFrom),
appending any default actions missing from the saved list while preserving
the user's existing order and toggles.
Capture the crashing thread's name from the UncaughtExceptionHandler and
surface it in the report (both in the device-info table and at the top of
the stack-trace block), so main-thread crashes can be distinguished from
coroutine-worker crashes at a glance.
The new Tune button inside the zap popup already opens the quick-zap
amounts editor, so the long-press shortcut from the zap button itself
(and from each chip inside the popup) is now redundant. Drop those
gestures and replace them with the custom-amount dialog that used to
live behind double-click:
- ZapReaction's button: long-press now opens ZapCustomDialog;
double-click is gone.
- Each chip in the popup: long-press now opens ZapCustomDialog
(via a new onCustomAmount callback threaded through the popup
overloads).
- NestActionBar's zap button wires onCustomAmount to its existing
wantsToSetCustomZap state.
- ReusableZapButton has no custom-zap dialog, so chip long-press
just dismisses the popup there.
The Tune button used the 40dp reactionBox with a 28dp icon, which was
visibly larger than the surrounding bolt chips. Use a compact 32dp box
with a 18dp icon so it lines up with the bolt glyph inside each chip.
The bolt-icon pill chips and the Tune settings button have different
intrinsic heights, so FlowRow's default top alignment leaves the
settings button visually higher than the chips. Set itemVerticalAlignment
so every item is centered within the row.
Media3's MediaSessionService.onStartCommand calls stopSelfSafely() when
delivered an intent (e.g. MEDIA_BUTTON from a headset) while no playback
is active. stopSelfSafely() invokes startForeground() to detach the
notification before stopping, which Android 12+ rejects when the app is
backgrounded with ForegroundServiceStartNotAllowedException.
Swallow that case in onStartCommand and stopSelf, matching the pattern
already used in NotificationRelayService.
https://claude.ai/code/session_01KTZDxK7zacrhFfqMHqt8dm
Wrap the zap-amount choices in the same elevated card / surfaceVariant
container used by the reactions popup, replace the flat primary-colored
material Buttons with pill-shaped Bitcoin-orange chips that pair a bolt
icon with the amount, and add a Tune action at the end of the row (the
zap equivalent of the AddReaction button in the reactions popup) so the
user can jump straight to editing their quick-zap amounts.
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
The Notifications feed could occasionally render as two stacked
LazyColumns — visible as ghosted text and "only the top one scrolls"
because both LazyColumns share the same `listState`. Reproduced by
double-tapping the bottom-nav Notifications button (each tap calls
`invalidateDataAndSendToTop(true)` → `clear()` + `refreshSuspended()`).
`RenderCardFeed` wrapped its `when (feedState)` in `CrossfadeIfEnabled`
→ `MyCrossfade`. `CardFeedState.Loaded` is a plain class with identity
equality, so back-to-back refreshes that transit through `Empty` or
`Loading` and back to `Loaded` inside the 100 ms animation can leave
two `Loaded` instances pinned in `currentlyVisible`. Each one composes
its own `FeedLoaded { LazyColumn(...) }`, both bound to the same
`LazyListState`, and only the topmost gets scroll input.
Switch directly on `feedState` with no crossfade — the animation was
only 100 ms and barely perceptible. Other CrossfadeIfEnabled callers
are untouched.
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
Two things turned out to be the same bug:
1. New calendar events arriving from relays didn't show up in either
feed without a manual pull-to-refresh. The screen was stuck on
whatever the last full refresh saw.
2. After a top-nav filter switch the feed looked like it was reflecting
"a previous state" — same root cause: filter switch ran a full
refresh from LocalCache, but events that the new subscription
subsequently delivered were dropped on the floor.
AccountFeedContentStates.updateFeedsWith / deleteNotes had calls for
every other feed but neither calendarAppointmentsFeed nor
calendarCollectionsFeed — so LocalCache.live.newEventBundles flowed
past them. Added both calls (and the matching deleteFromFeed entries)
so new appointments/collections insert into the visible feed live.
Also dropped the redundant "Calendar Lists" label above the top-nav
filter spinner on the collections screen — it duplicated the screen
title shown by the navigation chrome.
When loadRelayInfo was invoked from a main-thread Compose coroutine and
the call got cancelled, okhttp's executeAsync cancellation handler
closes the Response on the resuming dispatcher (AndroidUiDispatcher).
Closing the response drains any unread bytes from the SSL socket, which
triggers a blocking read on the main thread and throws
NetworkOnMainThreadException — surfaced to the user as
CompletionHandlerException.
The previous withContext(Dispatchers.IO) was nested inside the .use {}
block, so neither the initial suspension at executeAsync() nor the
implicit response close in the cancellation handler ran on IO. Moving
the dispatcher switch to wrap the entire function ensures the
continuation is dispatched on IO and the cancellation/close path runs
there too.
Self-audit of the previous MLS reply commit surfaced four concrete
improvements:
1. Push-notification cold-start replies now thread reliably. The
receiver previously rebuilt the parent inner event by looking it up
in LocalCache; in a cold-process broadcast that cache hasn't been
re-hydrated yet (Account.restoreAll runs async on init), so the
q-tag silently dropped. Carry the parent's inner event id AND
author pubkey through the Intent extras and feed them straight to
MarmotManager.buildTextMessage, which now takes (eventId, author)
instead of a full Event. The cold-start reply is now always
threaded, not just the warm-cache case.
2. marmotGroupRelays() is no longer duplicated. The receiver was
reimplementing what AccountViewModel had as a private fun (which
itself was used 9× inside the VM). Lifted to Account so headless
callers can reach it without spinning up a ViewModel; both sites
now share one implementation.
3. Dropped the dead editFromDraft / draftId plumbing. Added for route
symmetry with NIP-17 but Marmot has no draft persistence, so the
parameter rode all the way through MarmotGroupChatView only to be
ignored under @Suppress("UNUSED_PARAMETER"). Per CLAUDE.md, don't
pre-emptively abstract; reinstate when drafts actually land.
4. MarmotGroupMessageComposer no longer has default-param `remember`
blocks. There's exactly one caller and it always passes both
messageState and replyTo — the defaults were just noise.
The in-chat send path also captures (id, pubKey) under the replyTo
guard before launching the send coroutine, so a slow send + a user-
cleared reply state can't race into a partially-threaded message.
No protocol or behavioral change for the warm-cache happy path; the
threading improvement is observable only on cold-start push-reply.
Calendar collections (kind 31924) on the Calendar Lists screen routed
through Route.Note → ThreadView, which rendered them as a regular note
with the calendar metadata block at the top — no idea that the
collection is a curated list of appointments.
Fix:
- CalendarCollectionsView: route taps to Route.CalendarEventDetail
(using the collection's own Address) like every other calendar surface.
- CalendarEventDetailScreen: branch on event kind. CalendarEvent (31924)
now renders a new CollectionBody — title, description, ReactionsRow,
and a list of the collection's member appointments. Each member row
uses observeNote so an appointment that arrives later in the session
fills in without leaving the screen; tapping a row routes to its
appointment detail.
The "Add to phone calendar" icon I added on the calendar detail screen
top bar mapped to MaterialSymbols.EventAvailable (\\uE614), but the
checked-in TTF was a subset built before that codepoint existed in
MaterialSymbols.kt — so the third icon from the right rendered as the
.notdef placeholder.
Regenerated via tools/material-symbols-subset/subset.sh. The subset is
now 214 codepoints (up from 213); file size unchanged at 420K.
LocalCache stores notes via WeakReference (LargeSoftCache), so while the
user is on People List B the GC can reclaim the global calendar notes
that aren't strong-referenced from B's UI. When the user flips back to
Global, the relay subscription rebuilds with `since = lastEose` for
Global — which still says "you already have everything up to T" from the
prior visit. The relay won't re-send events with createdAt < T, and the
ones that were evicted from LocalCache stay gone. Symptom: switching
back to Global shows fewer events, or only past events, or nothing.
Fix: when the calendar listName changes, drop the EOSE cursor for the
list we're switching to before invalidating filters. The next assembly
runs with a fresh `since` (defaultSince / oneMonthAgo) and the relay
re-streams the events the cache lost. Added a `clear` method on
EOSEByKey + EOSEAccountKey and a protected `clearEoseFor(key)` helper
on PerUserAndFollowListEoseManager.
Same shape of bug likely affects pictures/discovery/etc. that share this
assembler pattern — left those alone for now and can apply the same
fix if reported.
Two related fixes for the calendar surface:
1. Filter switch leaving the user in the past section. The feed-content
state already calls sendToTop() when feedKey changes, but the calendar
feed's LazyColumn never subscribed to the scrollToTop signal — so a
filter switch (e.g. People List → Global) preserved the previous
scroll position. Often that landed the user mid-feed in the "Past"
section of the much larger Global list, looking like "the events
didn't come back". Added a LazyListState + WatchScrollToTop wiring.
2. Week/month/day headers stayed pinned when the DisappearingScaffold's
top bar collapsed on scroll. The views had a Column with
disappearingScaffoldPadding() that reserved a fixed top inset, so when
the bar slid up the headers stayed put and a visual gap opened. Pulled
the nav header (and for the week view, the WeekStrip + DaySummaryHeader)
into the same LazyColumn as the events, with rememberFeedContentPadding
as contentPadding — now the whole stack scrolls under the bar together.
The outer scroll Column had verticalArrangement.spacedBy(16.dp), which
compounded with the ReactionsRow's own internal vertical padding to make
the row look like it had huge top/bottom margins (~22dp on each side).
Drop the blanket spacedBy and place spacing deliberately: small explicit
Spacers between divider-separated sections, none around the ReactionsRow
itself so its 6dp internal padding is what shows.
- The calendar event/collection cards' top user header showed the note's
createdAt time-ago, which read as "the event time" at a glance — but
it's actually the publication time. Added `showTimeAgo` to UserCardHeader
and pass `false` from both calendar card surfaces so the only timestamp
on the card is the event's own start/end.
- Replaced the New Calendar Event participant input (paste an npub or
64-char hex) with the standard UserSuggestionState + ShowUserSuggestionList
pattern used by the badge-award / DM / new-post screens. Typing a name,
npub, hex, or NIP-05 surfaces matching users live; tapping one adds
them.
NIP-52 \`location\` is free-text — some events stash a Zoom / Jitsi /
livestream URL there instead of a place name. The detail row was sending
every value through the geo: intent, which on most devices punts to the
maps app and renders the URL as a search query.
Now: if the trimmed value starts with http:// or https://, open it with
ACTION_VIEW on the URL itself (which the browser claims). Otherwise the
geo: path is unchanged. The leading icon and content description flip to
Link / "Open link" when it's a URL so the affordance is clear before the
tap.
The DisappearingScaffold places content at y=0 by design (so feeds scroll
behind the bar) and exposes the bar height via [LocalDisappearingScaffoldPadding]
for inner scrollables to consume. The static-headered grid views and the
collections list weren't reading that local, so:
- The week view's WeekStrip (day-number row) rendered behind the top bar.
- The month/year title and grid header sat behind the bar similarly.
- The collections LazyColumn's first card was clipped under the bar.
Fix:
- Add a small `Modifier.disappearingScaffoldPadding()` helper that applies
the local padding. Month/week/day views opt in with one line.
- Collections LazyColumn uses `rememberFeedContentPadding(FeedPadding)`
so cards inset on first render but still scroll behind the bar (matching
the feed view's behaviour).
DAL extraction (commons/src/jvmAndroid/.../model/nip52Calendar/):
- CalendarSortKeys, CalendarAppointmentView, MonthGridBars, IcsExport now
live in commons so desktop and the future CLI can consume them. Package
changed to com.vitorpamplona.amethyst.commons.model.nip52Calendar.
- IcsExportTest moved to commons/jvmTest so it can see the `internal`
escapeText helper without exposing it as public API.
- Feed-filter classes (CalendarAppointmentsFeedFilter,
CalendarCollectionsFeedFilter) stay in amethyst — they depend on Account
and LocalCache. The ViewModels stay for the same reason; lifting them
requires moving Account/LocalCache too, out of scope here.
- A few smart-cast call sites needed local bindings because cross-module
properties don't support implicit smart-cast.
Accessibility:
- Each month-grid cell announces a full content description ("Wednesday,
January 15, 2025, 2 events, today, selected") via mergeDescendants so
TalkBack reads the cell as one item with role=Button.
- Week-strip cells get the same treatment with role=Tab.
- Header title now announces both the title and "jump to today" so the
affordance is discoverable.
- The expanding FAB describes itself as a toggle, and each sub-FAB as
the concrete create action.
Inline-FQN cleanup pass:
- CalendarEventDetailScreen (already in a prior pass), CalendarCollectionsView,
NewCalendarEventScreen, NewCalendarCollectionScreen: hoisted
fully-qualified androidx.compose / com.vitorpamplona references into
proper imports per the codebase style.
All 56 calendar tests still pass (48 in amethyst + 8 IcsExport in commons).
- New IconButton in the detail screen opens the system event composer
(Google Calendar / Samsung / iCloud) pre-filled with title, range,
location, and description via Intent.ACTION_INSERT on CalendarContract.
Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
cells a multi-day event covers. Bars are laid out by a greedy
lowest-lane assignment so overlapping events stack rather than collide;
cells removed their horizontal padding so adjacent bars merge into one
uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
boundaries), so a 3-day conference reads as one continuous pill across
the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
collision, longer-event tiebreak, and the empty/ghost edge cases.
- ReactionsRow on the detail screen so zap/like/repost/reply use the same
shared component every other note surface uses (no calendar-specific
reinvention)
- Multi-day events now appear on every day they cover in month/week/day
grids (groupByDayKeyExpanded), with "Day X of Y" / "Continues" markers
on continuation rows
- Horizontal swipe gestures on month/week/day views via a shared
calendarSwipeNavigation modifier
- Deep-link prefetch: notification taps on a calendar naddr route
directly to CalendarEventDetail (skipping EventRedirect) and the
detail screen issues a per-event relay subscription via observeNote
- "Happening now · ends in X" relative-time label for ongoing events
- Richer empty-state copy with a shared CalendarEmptyState composable
(feed / day / week / collections each get a title + actionable hint)
- Cleanup: hoist inline fully-qualified imports across the detail screen