RSVP (item 4):
- CalendarRsvpRow now uses a deterministic d-tag of the form
'rsvp:<kind>:<pubkey>:<dtag>' derived from the target appointment's
address. Each tap replaces the user's single addressable RSVP for that
event rather than appending another, eliminating the previous footgun
where rapid taps spammed relays with parallel kind-31925 events.
- Buttons now reflect the current RSVP status reactively: the matching
status renders as filled-tonal with the status colour; the others
render outlined. Reads `LocalCache.getOrCreateAddressableNote()` and
observes the metadata flow so the row updates as soon as the new
event lands in cache (own broadcast or relay echo).
Collections (item 5):
- Lifted NewCalendarCollectionScreen onto NewCalendarCollectionViewModel
with title/description/selected-event state. Editing mode pre-populates
from an existing kind-31924 by dTag (already supported by
Route.NewCalendarCollection but previously unused), preserves the
d-tag so the publish replaces the addressable, and seeds the
selected-event list from the existing `a` tags.
- Added a multi-select picker that lists the user's own appointments
(kinds 31922/31923 authored by `account.userProfile()`), sorted
upcoming-first. Toggling a row adds or removes its address from the
outgoing `a` tag list.
- Wired Route.NewCalendarCollection.dTag through to the screen so the
edit flow becomes reachable from anywhere that has the calendar's
dTag (the event-detail screen will plug into this in a follow-up).
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Naming:
- CalendarsFeedFilter → CalendarAppointmentsFeedFilter, calendarsFeed →
calendarAppointmentsFeed. NIP-52 calls kind 31924 the "calendar" (a
list of events); kinds 31922/31923 are appointments that *go into*
calendars. The old name made `calendarsFeed` look like "the feed of
calendars" when it was actually the feed of appointments.
Architecture:
- Dropped CalendarsViewMode.COLLECTIONS. Calendar collections are a
sibling feed, not a view mode of the appointment timeline. They now
live on a dedicated CalendarCollectionsScreen reached via the new
Route.CalendarCollections, with their own drawer entry and bottom-bar
slot under NavBarItem.CALENDAR_COLLECTIONS.
- Both screens share the same CalendarsFilterAssembler subscription
(which already pulled all four kinds), so opening either keeps the
relay subscription warm for the other.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Correctness:
- Replaced millisecond arithmetic with LocalDate.plus/minusDays in day
and week views. Day stepping with MILLIS_IN_DAY drifts at DST
transitions (a day is 23h or 25h), so after a couple of spring/fall
crossings 'next day' landed on the wrong calendar date.
DRY:
- Introduced CalendarAppointmentView, a small projection that exposes
title/image/summary/location/start/end/isAllDay for both 31922 and
31923 events. Three call sites previously did a 4-block
`when (event) { is Time -> e.x(); is Date -> e.x() }` per accessor;
they're now single linear reads.
- Extracted CalendarNavigationHeader for the shared [◀] title [▶]
pattern used identically by month, week and day views.
- Moved groupByDayKey from CalendarMonthView.kt into the dal package
alongside calendarLocalDayKey — it's used by all three grid views,
not just the month view.
Cleanup:
- `Modifier.size(width = 72.dp, height = Dp.Unspecified)` in DayRow
was the residue of an earlier failed `width()` helper; replaced with
the native `Modifier.width(72.dp)`.
- Removed obsolete startOfWeekMs / dayKeyForMs / MILLIS_IN_DAY /
MILLIS_PER_DAY / MILLIS_PER_WEEK helpers along with their imports.
Line counts: MonthView 341→273, WeekView 299→234, DayView 270→196,
EventListCard 236→201.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Correctness:
- Day-cell grouping now uses local calendar dates (LocalDate.toEpochDay)
consistently across feed, month, week and day views. The previous
implementation keyed events by UTC year/month/day while cells used
local year/month/day, so a time-slot event at 2025-01-15 00:00 UTC
silently disappeared from the user's Jan-14 cell in zones west of UTC.
- 31922 date-slot events anchor at local midnight instead of UTC, so
"Jan 15" lands on Jan 15 in every viewer's grid (previously appeared
a day early west of UTC).
- Switched ISO date parsing to DateTimeFormatter; the SimpleDateFormat
predecessor was shared by sort (background) and grouping (UI) and is
not thread-safe — concurrent reads could throw or return garbage.
- Snapshot TimeUtils.now() once per sort; reading the clock inside
the comparator could violate transitivity on boundary elements and
trigger IllegalArgumentException from the JDK sort.
- Multi-day events that have started but not ended are now classified
as "upcoming" — previously a 3-day conference starting yesterday was
silently dropped into the past section.
Performance:
- Hoisted MonthShortFormatter as a file-level DateTimeFormatter; was
allocating a SimpleDateFormat per CalendarDateBadge recompose.
- Removed `derivedStateOf` over-keying on year/month/weekStart/dayMs;
groupByDayKey only depends on the note list.
- Dropped the wasted `remember { Any() }` indirection driving
LaunchedEffect — feed the keys directly.
Cleanups: removed unused UnusedShape, ChipPad, FeedCardPadding,
IconTintPlaceholder, startOfDayLocal, dayKeyUtc, startOfWeekMsForNote,
and the dead `dayCal` line inside MonthGrid.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds NewCalendarEventScreen (Material3 form with all-day toggle,
DatePicker/TimePicker chain, location, summary, image, hashtags)
and NewCalendarCollectionScreen (title + description). Both publish
via the standard signAndComputeBroadcast pipeline and are wired
into AppNavigation as bottom-up routes.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Calendar appointment cards now show Going/Maybe/Can't-go buttons that
publish a NIP-52 RSVP (kind 31925) when tapped. Calendar collections
(kind 31924) and RSVP events (kind 31925) get dedicated inline renders
wired into NoteCompose so they no longer fall through to the generic
unknown-kind path.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Adds the parent CalendarsScreen with Feed/Month/Week/Day/Collections
view-mode chips, a drawer + bottom-bar slot, top-bar follow-list
filter, and a FAB menu that opens the create flows. Feed view splits
events into Upcoming / Past sections; Month view is a 7-column grid
with event-dot indicators per day; Week view is a 7-day chip strip
with the selected day's events listed below; Day view stacks events
on a vertical timeline. Calendar collections (kind 31924) get their
own list view.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
Wires per-account calendar follow-list settings, two feed filters
(appointments 31922/31923 and collections 31924), and a relay
subscription assembler that pulls all four calendar kinds. Sorts
the appointment feed with upcoming events first, past events after,
so the calendar timeline behaves like a calendar rather than a chat
feed.
https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
On AOSP / GrapheneOS without Google Play Services (or microG), Android's
system Geocoder has no backing IGeocodeProvider, so `Geocoder.isPresent()`
returns false. Two compounding bugs left the Around Me top-bar spinner
running forever in that case:
1. `CachedReversedGeoLocations.geoLocate` only invoked `onReady` when
Geocoder was present AND returned a non-null, non-blank city name. In
the not-present and empty-result paths the callback never fired.
2. `LoadCityName` looped while `notReady`, but `notReady` was only flipped
off when `newCityName != cityName`. With both null the loop never
terminated, doubling its delay each pass.
Now `geoLocate` always calls `onReady` exactly once, short-circuiting
with `null` when no Geocoder backend exists. `LoadCityName` bridges the
callback to a suspending call, caps retries at 5, and falls back to
rendering the raw geohash so the spinner always stops. The "no geocoder
backend" check short-circuits the whole retry path on devices that will
never be able to resolve a name.
Earlier commits kept I2pType.INTERNAL as a placeholder for a follow-up
embedded daemon. We're not shipping that: I2P bootstrap on Android is
structurally minutes-long (no equivalent of Tor's hardcoded directory
authorities — NetDB peer discovery is protocol-inherent), so an embedded
router would mean a permanently-warming UX. Users who want I2P run i2pd /
Java I2P independently and point Amethyst at its SOCKS port.
Changes:
- commons I2pType drops the INTERNAL variant; parseI2pType collapses unknown
codes to OFF
- I2pManager loses its INTERNAL switch arm
- I2pSettingsDialog drops the "treat INTERNAL as OFF" mapping it used to
carry — the enum no longer has the case
- Android UI I2pSettings.resourceId drops the i2p_internal branch
- strings.xml drops the now-unused i2p_internal string
- I2pSharedPreferences hardens load: a stored "INTERNAL" from an earlier
branch is no longer a valid I2pType, so runCatching swallows the
IllegalArgumentException and the user lands on OFF
- PrivacyRouterTest fixtures use I2pType.EXTERNAL throughout
The close animation lerped from the image's unzoomed layout bounds to
the source thumbnail rect, so dismissing a zoomed-in image jumped: the
image was visible at the inner zoomable's transformed bounds but the
exit animation rewound from the layout bounds instead.
Hoist the per-page ZoomState up to the dialog and feed its live scale +
offset into the outer graphicsLayer, so the grow/shrink animation
matches whatever the user is currently seeing on screen. At identity
zoom (entry case) behavior is unchanged.
nostr-protocol/nips#2332 adds optional ["block", …] and ["proof", …] tags
on kind:8333 that ship the SPV proof inline. The data-model side is already
shipped in Quartz (BlockTag, ProofTag, OnchainZapEvent.block()/.proof() and
the TagArray helpers), but no production code path produces or consumes
either tag yet.
amethyst/plans/2026-05-14-onchain-zaps.md — onchain zaps plan:
- Update the "Chain backend" decision bullet to flag the spec change and
the two production gaps (send-side emit, receive-side consume).
- Add a dedicated "Inline SPV proofs" section covering: spec status and
the merkle-proof encoding ambiguity flagged back to the PR; per-layer
status matrix (shipped vs gap, with file locations); send-side
two-publish design (Design B — keep instant receipt, add post-confirm
republish with block+proof; dedupe by (txid, target)); receive-side
fast-path with fall-through on proof failure (never hard-reject);
phased delivery G.1–G.7 with effort estimates (~3–4 d after S1 ships
and spec encoding lands).
- Add the two new pending items to the existing "What's still pending"
list to keep that section authoritative.
quartz/plans/2026-05-08-local-headers-explorer.md — headers-explorer plan:
- Rewrite §19 (follow-up onchain-zap verification) to reflect the spec
change. Original section assumed we'd need BIP-37 merkleblock or
full-block fetch over P2P; with the proof inline none of that
infrastructure is required. Estimate collapses from 10–15 d to ~3–4 d.
- Point §19 at the full implementation plan in the onchain-zaps file,
keeping S1 focused on OTS while the follow-up details live with the
rest of the NIP-BC work.
Every consensus-relevant layer of the planned headers explorer (BlockHeader80
parser, DifficultyTarget compact↔target, CalculateNextWorkRequired retarget,
MedianTimePast, header validator end-to-end, P2P wire codecs, reorg/chain
selection, OTS proofs) is pinned to upstream test vectors committed under
quartz/src/commonTest/resources/bitcoin/, matching the existing
nip44.vectors.json / bip39.vectors.json / mls/*.json pattern.
The single highest-value test is a nightly differential check asserting
LocalHeadersBitcoinExplorer.blockHash(h) == OkHttpBitcoinExplorer.blockHash(h)
for every height in [checkpoint, tip] — any consensus drift surfaces as a
disagreeing height.
Maps cleanly onto the existing Phase 1/3/4/5/7/9 work, adding ~5–7
engineer-days total to the plan budget. No new top-level phase needed.
LocalCache.getNoteIfExists(event.id) returns the id-keyed version note,
which has its replyTo moved to the replaceable note during insertion
(consumeBaseReplaceable). The id-keyed lookup therefore returns an empty
replyTo for AddressableEvent kinds — LongTextNoteEvent, WikiNoteEvent,
LiveChess*, VideoHorizontal/Vertical — and NotificationFeedFilter's
replyTo-based check in tagsAnEventByUser silently fails for replies into
long-form articles or wiki notes.
Switch to LocalCache.getNoteIfExists(event), which dispatches on
AddressableEvent and returns the address-keyed note with proper replyTo.
Applied in three places: the dispatcher predicate, consumeFromCache's
per-event match, and dispatchForAccount's muted-thread check (the last
one only handles non-addressable kinds today but is updated for
consistency).
Lock the design choices from the 2026-05-19 review against the intervening
2026-05-14 onchain-zaps work:
- Move the module from quartz/.../nip03Timestamp/bitcoin/ to a sibling
quartz/.../bitcoin/ package so the headers explorer, header validator,
peer pool and store can be reused by the future onchain-zap merkle-proof
work without an inverted import path.
- Use androidx.sqlite + BundledSQLiteDriver for HeaderStore, matching the
existing SQLiteEventStore. Schema lives in commonMain via IModule. Drops
the hand-rolled flat-file + sidecar height index.
- Drop the bundled headers blob. Ship a single hardcoded PinnedCheckpoint
constant; first-run sync starts from the checkpoint and pulls forward
over P2P. APK growth: 0 bytes.
- Pre-checkpoint OTS heights fall through to OkHttpBitcoinExplorer via
BitcoinExplorerEndpoint (shared with the onchain-zap EsploraBackend).
Strict-mode users get an explicit error instead of a network call.
- Mark trustless NIP-BC onchain-zap verification as out of scope and
capture it as a follow-up plan (BIP-37 merkleblock or full-block fetch
on top of this stack).
Resolves open questions Q1, Q2 and Q4 from the original plan; Q3 (Quartz
public API vs internal) left open for Phase 0.
- WakeUpEvent.notifies is unconditionally true (wake every signed-in
account). The previous commit's isTaggedUser-only routing dropped
WakeUps that didn't happen to p-tag a logged-in pubkey, breaking the
wake-the-device-for-everyone semantic. Both the dispatcher predicate
and consumeFromCache now bypass the feed-style match for WakeUpEvent.
- LocalCache.getNoteIfExists(event.id) is hoisted out of the per-pubkey
any-loop in the dispatcher predicate and out of the per-account forEach
in consumeFromCache. The note is the same for every account, so one
lookup per event is enough.
- Drop RepostEvent / GenericRepostEvent from the new muted-thread check
in dispatchForAccount: they aren't routed below to any notify(), so the
guard was partial dead code. Comment updated to call out that push has
no repost notifier today (the feed does — separate gap).
- Comment on the dispatcher predicate now flags the two behavior deltas
vs. the old event.notifies routing: WakeUp bypass, and NIP-22 Comments
where the user is only the root author (uppercase `P`) no longer push.
A newer AddressableEvent (e.g. VideoVerticalEvent) arriving on a relay
thread can swap a Note's event mid-sort, changing the createdAt value
the comparator returned moments earlier. TimSort then trips
"Comparison method violates its general contract!" and the feed
refresh crashes.
Snapshot createdAt once per note before sorting via a new
sortedByDefaultFeedOrder() helper.
Push notification routing now uses the same recipient + per-kind logic the
in-app Notifications feed uses (isTaggedUser + tagsAnEventByUser), so the
two surfaces agree on what counts as a mention, reply, citation, fork, or
community moderation. Adds the explicit muted-thread check for
reactions/zaps/reposts that the feed performs on the target note.
tagsAnEventByUser is hoisted to NotificationFeedFilter's companion so the
dispatcher and consumer can call it without depending on a feed instance.
The playback notification's tap target (MediaSession.setSessionActivity)
was only bound from MediaSessionCallback.onAddMediaItems, which fires
when the in-app MediaController calls setMediaItem. GetVideoController's
warm-pool fast path skips setMediaItem when the acquired ExoPlayer was
retained with the same mediaId, so the new MediaSession was left with
no session activity and tapping the notification did nothing.
Bind the PendingIntent in newSession() from the acquired player's
current MediaItem extras, and share the construction with onAddMediaItems
via a single helper.
Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).
- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
interface for async lookup. The cache is primed by outbound zaps (existing
LightningAddressResolver fetches now extract nostrPubkey) and on demand for
inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
AppModules using the existing money-tier OkHttp builder (so Tor settings
apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
synchronously when the cache is warm, defers credit until async resolution
finishes on cache miss, and falls back to legacy signature-only behavior
when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
threaded through Account.createZapRequestFor and emitted as tags so future
receipts can be validated against them.
21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
When a filter (e.g. Non-Zaps) excluded every loaded transaction, the
empty-state composable replaced the whole screen body — including the
filter chip row — so the user couldn't switch back to All or Zaps
without leaving the screen. Expose `hasAnyTransactions` from the
ViewModel so the screen distinguishes "no chain rows at all" from "no
rows for this filter": the chips stay rendered as long as any
transaction has loaded, and the LazyColumn shows a per-filter empty
message inline beneath them. Also drop the bc1 address header from the
list since the screen title already identifies the wallet.
Two related fixes to the thread quick-action popup:
- ThreadFeedView.FullBleedNoteCompose declared a `modifier` parameter
that NoteMaster used to attach `combinedClickable(onLongClick = showPopup)`,
but the body built a fresh `Modifier.fillMaxWidth().padding(top = 10.dp)`
for its root Column and discarded the incoming modifier. Long-press on
the root note in thread view never fired. Spread the incoming modifier
onto the Column.
- LongPressToQuickAction emitted the Popup as a sibling of the content
with no wrapping layout. The Popup's `parentLayoutCoordinates` then
resolved to the enclosing LazyColumn, so `alignment = Alignment.Center`
centered the menu on the whole list (visually middle of the screen)
instead of the long-pressed card. Wrap content + Popup in a Box so the
popup's parent bounds match the note card.