Commit Graph

13991 Commits

Author SHA1 Message Date
Claude eeda5810ae feat(calendars): share-as-nostr-link, reactive picker, calendar filter
#8 Share-as-nostr-link:
- Second share IconButton on the event-detail top bar (next to the
  existing .ics share). Builds a nostr:naddr1… URI via NAddress.create
  and hands it to the system share sheet as text/plain. Lets users
  paste a calendar event into DMs, posts, or any other nostr client
  that understands naddr URIs.

#10 Reactive availableAppointments in collection editor:
- NewCalendarCollectionViewModel now subscribes to
  LocalCache.live.newEventBundles in a viewModelScope job and
  re-scans loadOwnedAppointments() on every emit. New appointments
  published from another screen (or arriving from relays) while the
  editor is open now appear in the picker without dismissing.
- onCleared() cancels the subscription so the VM doesn't leak the
  collector after the screen closes.

#11 Calendar-as-filter overlay:
- New CalendarFilterChip composable: an AssistChip in the top-bar
  trailing slot showing "All" or the selected calendar's title. Tap
  opens a ModalBottomSheet with radio rows for "All" + each of the
  user's own kind-31924 calendars. Sheet is reactive so newly-
  published calendars appear immediately.
- rememberCalendarFilterAddresses() resolves the selected calendar's
  member-address set (or null when "All"). The Set lives one
  composition above the view bodies and reacts to LocalCache changes
  via produceState.
- Feed / Month / Week / Day views each take an optional
  filterAddresses parameter. A new List<Note>.applyCalendarFilter()
  extension does the membership check. Client-side filtering means
  flipping the filter doesn't trigger a relay refetch.
- CalendarsTopBar grew a generic trailing slot so future controls
  can plug in alongside the view-mode chips without further surgery.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:34 +00:00
Claude 3315ccccec feat(calendars): reminder settings (enable + lead time) and store tests
#5 Configurable reminder lead time + #6 enable/disable toggle:
- CalendarReminderPrefs is a device-level SharedPreferences wrapper
  with isEnabled / leadMinutes accessors. Device scope (rather than
  per-account) because the worker that consults it runs globally —
  per-account preferences would require account-context plumbing into
  WorkManager that the rest of the app doesn't have today.
- CalendarReminderWorker now reads enabled + lead-minutes on each
  cycle. When disabled the worker short-circuits to Result.success()
  rather than cancelling itself — flipping the toggle back on takes
  effect immediately without a relaunch.
- New CalendarReminderSettingsScreen: a Switch for enabled, a row of
  FilterChips for the 5/15/30/60-minute lead-time choices. The lead-
  time row disables when reminders are off.
- New Route.CalendarReminderSettings wired through AppNavigation and
  surfaced as an entry in the existing AllSettingsScreen list under
  the notification settings divider.

#7 Tests:
- CalendarReminderPrefsTest exercises the prefs round-trip
  (defaults, set/get of enabled and lead-minutes) and the store
  contract (wasNotified false-by-default, true after markNotified,
  false again when start changes, and forgetBefore pruning).
- Backed by an in-memory FakeSharedPreferences so the tests run on
  the JVM without Robolectric. mockk stubs the Context to hand back
  the fake prefs.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude a5f03b21cc feat(calendars): gallery image picker + participant picker on create
#3 Image upload from gallery:
- NewCalendarEventViewModel.uploadAndSetImage(uri, mime, context)
  picks up the user's configured default file server (Blossom / NIP-96
  / NIP-95), uploads via UploadOrchestrator with MEDIUM compression and
  metadata stripping, then writes the resulting URL into imageUrl. On
  failure the screen surfaces a toast and the URL field stays usable
  for the manual fallback.
- New ImageRow on the create screen: keeps the URL OutlinedTextField
  but adds an AddPhotoAlternate icon button next to it. While the
  upload runs the field disables and a small CircularProgressIndicator
  takes the icon's slot.

#9 Participant picker:
- New `participants` mutableStateListOf on the VM, plumbed through
  publish() as `p` tags via the existing dayParticipants/timeParticipants
  TagArrayBuilder extensions. Edit-mode load pre-populates from the
  event's existing p-tags.
- New ParticipantsRow on the create screen: an OutlinedTextField that
  accepts an npub or a 64-char hex pubkey, with an Add button that
  resolves via Nip19Parser. Existing participants render as the
  standard avatar + display-name row with an X button to remove.
  Invalid input shows an inline error.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude 993810aa2f feat(calendars): add-to-calendar picker, delete collections, fix maps row
#1 Add-to-calendar bottom sheet:
- New AddToCalendarSheet shows the user's own kind-31924 calendars with
  a checkbox per calendar. Tapping a row toggles membership of the
  current event in that calendar and re-signs with the updated `a` tag
  list. Reactive — collects LocalCache.live.newEventBundles so own-just-
  published edits and incoming calendar events update the sheet
  without dismissing.
- Trigger is a + icon next to the "In calendars" section title on the
  event-detail screen. Empty-state shows a hint when the user has no
  own calendars yet.

#2 Delete collections:
- NewCalendarCollectionViewModel.deleteLoaded() builds a NIP-09
  deletion via account.delete(). No-op outside edit mode.
- Edit screen now shows a red OutlinedButton "Delete calendar" below
  the form. Tapping opens an AlertDialog with a confirmation — the
  confirmation clarifies that only the calendar list is removed; the
  individual events inside continue to exist.

#4 Maps row:
- LocationRow lost the nested-but-non-functional "Open in maps"
  TextButton. The row is the single click target with a trailing
  ChevronRight icon to signal "tap to navigate". Content description
  on the LocationOn icon now carries the action name for TalkBack.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude cc5ae46f5b feat(calendars): modernize UI to match the rest of the app
Visual: every calendar surface now uses the same avatar + display-name
pattern as the picture feed / shorts / video cards — UserCardHeader at
the top of each list card, ClickableUserPicture + UsernameDisplay in
the detail screen's people sections. Truncated pubkeys (`pub…XX`) only
appear as a fallback while LoadUser resolves a real metadata record.

Cards:
- CalendarEventListCard now starts with UserCardHeader (avatar, name,
  time-ago, more-options) above the date badge + body. Layout matches
  PictureCardCompose / VideoCardCompose so the calendar feed reads as
  part of the same product, not a tacked-on tab.
- CalendarCollectionCard same treatment.

Detail screen:
- ParticipantsSection now renders a 35dp avatar + display name per
  p-tag, tappable to the user's profile.
- RsvpsSection renders the RSVP author the same way with the status
  badge as a trailing element (Going / Maybe / Can't go in the matching
  scheme colour).
- InCalendarsSection renders each calendar's author avatar alongside
  the calendar title — clicking the row still navigates to that
  calendar's detail.
- RSVPs + calendars sections are now reactive: produceState collects
  LocalCache.live.newEventBundles and re-runs the scan, so RSVPs that
  arrive from relays while the screen is open appear without leaving
  and returning.

Notifications:
- Tap the notification → opens the calendar event detail. The reminder
  PendingIntent now carries the `nostr:naddr…` URI as ACTION_VIEW data;
  AppNavigation's existing uriToRoute pipeline resolves it via NAddress
  → RouteMaker, where new branches map CalendarTimeSlotEvent /
  CalendarDateSlotEvent to Route.CalendarEventDetail. Previously the
  tap just opened the home screen.
- CalendarReminderStore now keys on (eventId, startSeconds). If the
  author updates the appointment with a new start time, the stored
  value won't match and the worker fires a fresh reminder for the new
  time — previously a moved meeting would be silently skipped because
  the eventId already had a record.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude 55147b7781 feat(calendars): "starting soon" notifications for attended events
Adds a 15-minute periodic WorkManager job that scans LocalCache for
kind-31925 ACCEPTED RSVPs whose target appointment starts within the
next 15 minutes, and posts a notification per event.

- CalendarReminderNotifier: posts notifications on a dedicated
  "Calendar reminders" channel; pattern-matches the existing
  ScheduledPostNotifier shape so the two notification surfaces share
  conventions. Per-event ID derived from the appointment's event id so
  a re-notification for the same event collapses rather than stacks.
- CalendarReminderStore: SharedPreferences-backed "already notified"
  set keyed by event id, with the recorded start time as the value so
  the entry can be pruned when the event has ended (forgetBefore()).
  Without persistence, every worker run after a restart would re-fire
  the same reminders until the event started — LocalCache has no
  memory of past reminders.
- CalendarReminderWorker: 15-minute periodic CoroutineWorker that
  walks accepted RSVPs in LocalCache, resolves the target appointment
  via its addressable address, and posts the reminder if it's in the
  lead window and not previously notified. Skips multi-account
  coordination — accepts any RSVP in cache regardless of which
  account authored it.
- Hooked into AppModules alongside the scheduled-posts worker
  (independent of the always-on notification toggle so reminders fire
  even when that's disabled). POST_NOTIFICATIONS is already declared
  in the manifest.
- Strings: channel id/name/description, default title fallback, and
  "Starts in N minutes" body template.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:33 +00:00
Claude 8d35a73416 feat(calendars): relative-time labels and iCalendar (.ics) export
Relative time:
- New relativeTimeLabel() wraps Android's DateUtils.getRelativeTimeSpanString
  for locale-aware "in 2 hours" / "yesterday" / "in 3 days" phrasing. Uses
  DAY resolution for all-day events so a 31922 doesn't get a misleading
  hour-precision phrase.
- Special-cased to "Happening now" when an event has started but its end
  is still in the future — DateUtils would otherwise produce "started 5
  minutes ago" which reads wrong while the user is mid-event.
- Wired into the appointment list card (below the date range) and the
  event detail screen (between the range and the location).

iCalendar export:
- New IcsExport object generates RFC 5545 text. Produces a single-event
  VCALENDAR for appointments and a multi-VEVENT VCALENDAR for kind-31924
  collections (member events that haven't arrived from relays yet are
  skipped). Date-slot events emit DTSTART;VALUE=DATE so calendar apps
  don't render them as midnight events. Text escaping handles backslash,
  comma, semicolon, and newline per §3.3.11.
- Share button (MaterialSymbols.Share) on the event detail screen and
  on each collection card. shareIcs() writes the file to cacheDir/calendar
  and hands it to the system share sheet via the existing FileProvider,
  with FLAG_GRANT_READ_URI_PERMISSION scoped to the chooser.

Tests:
- 8 new IcsExportTest cases covering time-slot vs date-slot output,
  escaping, hashtag→CATEGORIES, multi-event calendar wrapping, and
  filename safety/fallback.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude 18512e11ff feat(calendars): edit existing appointments
Adds an edit flow for kind-31922/31923 appointments authored by the
current account. The detail screen surfaces a pencil icon in the top
bar when isOwnEvent is true; tapping it navigates to the new
Route.EditCalendarEvent(kind, pubKeyHex, dTag), which routes to the
existing NewCalendarEventScreen in edit mode.

ViewModel:
- NewCalendarEventViewModel.loadForEdit() pre-populates all fields
  (title, summary, location, image, hashtags, start/end seconds) from
  the cached event. It's idempotent across recompositions and a no-op
  if the address isn't in LocalCache yet.
- publish() now preserves the addressable's d-tag and kind in edit
  mode so the broadcast replaces the original rather than minting a
  new event.
- The all-day toggle is disabled while editing — switching kinds mid-
  edit would leave a stale event under the original kind/d-tag
  combination. The UI shows an explanatory subtitle.

Also escapes the leading `?` in calendar_rsvp_maybe_prefixed (aapt was
parsing it as a theme-attribute reference and refusing to link).

Tests:
- New CalendarEditLoadTest covers the round-trip parsing from packed
  tags back to the fields the VM reads — time-slot, date-slot, and an
  empty-optional-tags variant.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude 390d750638 i18n(calendars): replace hardcoded user-facing strings with resources
Removes the English literals that were leaking through user-facing
surfaces of the calendar feature:
- Empty-state copy in week and day views ("No events", "No events on
  this day").
- Navigation content descriptions ("Previous/Next month/week/day")
  used by accessibility services and TalkBack.
- DatePicker / TimePicker dialog buttons ("OK", "Cancel") and the
  TimePicker dialog title ("Pick time"); the OK side now reuses the
  app-wide R.string.confirm / R.string.cancel.
- "All-day" label in the day view's time column and the collection
  editor's appointment picker rows.
- "(untitled)" fallback shown in the picker and in the event-detail
  "In calendars" section.
- The RSVP-list status badges in the detail screen (✓ Going, ? Maybe,
  ✗ Can't go) — the glyph stays in the string so translators can keep
  or replace it per locale.

NewCalendarCollectionViewModel no longer embeds "(untitled)"; the
fallback moves to the picker row composable so the VM stays free of
string resources.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude bf380ec015 feat(calendars): event detail screen, string cleanup, unit tests
Detail screen (item 3):
- Adds Route.CalendarEventDetail(kind, pubKey, dTag) and a dedicated
  CalendarEventDetailScreen that loads the appointment by address. The
  screen renders the hero image, full title/range/location/summary,
  the RSVP button row, participants, all RSVPs from LocalCache that
  a-tag the event, and the calendars (kind 31924) this event belongs
  to. Tapping a parent calendar navigates back into the detail screen
  with that calendar's address.
- The "Open in maps" affordance fires a geo: intent for the location
  string, falling back gracefully when no maps app is installed.
- Tapping a card now navigates to the dedicated detail screen instead
  of the generic Route.Note when the event is addressable.

Strings cleanup (item 6):
- Removed unused entries that the audit flagged and that didn't end up
  wired into the detail screen: calendar_section_today,
  calendar_event_pick_time, calendar_event_publish,
  calendar_event_publishing, calendar_collection_save,
  calendar_rsvp_responded, calendar_rsvp_sending.

Unit tests (item 8):
- 27 tests covering the pure helpers — parseIsoDateToUnixSeconds,
  Note.calendarStartSeconds / calendarEndSeconds / calendarLocalDayKey,
  appointmentView, groupByDayKey, partitionUpcomingPast,
  upcomingFirstCalendarOrder transitivity, and rsvpDTagFor/Address.
- Made partitionUpcomingPast take `nowSeconds` as a parameter (default
  TimeUtils.now()) so the split is deterministic in tests — same
  pattern as upcomingFirstCalendarOrder.

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
2026-05-19 21:58:32 +00:00
Claude e901364e87 feat(calendars): RSVP dedupe + collection editing with event picker
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
2026-05-19 21:58:32 +00:00
Claude 447301599c refactor(calendars): rename appointments feed, split collections to own route
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
2026-05-19 21:58:32 +00:00
Claude 87f9a346cc refactor(calendars): DST-safe nav, shared header, appointment-view adapter
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
2026-05-19 21:58:32 +00:00
Claude 802f89723e fix(calendars): audit pass on day-keying, threading and recomposition
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
2026-05-19 21:58:32 +00:00
Claude 809f21252c feat(calendars): add create flows for events and collections
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
2026-05-19 21:58:31 +00:00
Claude 5343c7c636 feat(calendars): inline RSVP buttons and 31924/31925 renders
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
2026-05-19 21:58:31 +00:00
Claude b5d5cbed03 feat(calendars): add screens, navigation and view modes
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
2026-05-19 21:58:31 +00:00
Claude fcab611bc4 feat(calendars): add NIP-52 feed foundation, DAL and relay subscription
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
2026-05-19 21:58:31 +00:00
Claude 2326738b84 fix(marmot): route reply button on MLS messages to the encrypted group
Tapping reply on a Marmot/MLS (kind:445) message in the Notifications
screen previously fell through routeReplyTo()'s `else` branch and opened
the generic public-comment composer (Route.GenericCommentPost). Sending
it would publish a plaintext kind:1111 that references the encrypted
inner event id, leaking that the user has decrypted that group message.

Mirror the NIP-17 pattern (Route.Room carries replyId, draftId,
draftMessage; the same chat screen renders the "replying to" quote
above the input) for MLS:

- routeReplyTo() now detects MarmotGroupChatroom in note.inGatherers,
  matching how routeFor() at line 67 already finds the parent group, and
  returns Route.MarmotGroupChat(groupId, replyId = note.idHex).
- Route.MarmotGroupChat gains message/replyId/draftId fields.
- MarmotGroupChatView resolves the replyId into a Note, shows
  DisplayReplyingToNote above the composer, wires onWantsToReply for
  in-chat replies, and threads the parent inner event through
  AccountViewModel.sendMarmotGroupMessage into
  MarmotManager.buildTextMessage, which now adds a NIP-18 q-tag on the
  inner kind:9 (the same convention ChatEvent.reply() uses).

Push notifications: notifyGroupMessage previously passed
chatroomMembers=null, so the inline Reply action was never attached for
MLS group notifications. Add a parallel MARMOT_REPLY_ACTION wired with
the group id + parent inner event id; NotificationReplyReceiver loads
the account, rebuilds the parent inner event from LocalCache (or sends
unthreaded if the cache was pruned), and publishes the reply through
the same MarmotManager path — so the inline notification reply stays
encrypted inside the group instead of taking the NIP-17 PTag fallback.
2026-05-19 21:45:53 +00:00
Crowdin Bot 333a3f92ed New Crowdin translations by GitHub Action 2026-05-19 21:44:44 +00:00
Vitor Pamplona 6e37133b27 Merge pull request #2993 from vitorpamplona/claude/fix-geohash-loading-EJTQY
Refactor geolocation retry logic with suspend coroutines
2026-05-19 17:43:12 -04:00
Claude 6c4ff97cf1 fix(location): stop spinning Around Me when Geocoder is unavailable
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.
2026-05-19 21:32:11 +00:00
Vitor Pamplona 53d4a7ecae Merge pull request #2992 from vitorpamplona/claude/reorder-nav-filter-popup-b3WLj
Reorder FeedGroup enum for improved UX
2026-05-19 17:29:19 -04:00
Claude 77a9bc8754 feat: move DVMs before Communities in filter popup 2026-05-19 21:28:51 +00:00
Claude b82ad10ca5 feat: place interest sets next to hashtags in filter popup 2026-05-19 21:25:12 +00:00
Claude a568cbbc74 feat: reorder feed filter popup sections
Reorder sections in the top nav feed filter popup to: Feeds, Relays,
Hashtags, Locations, Communities, Lists, Interest Sets, DVMs.
2026-05-19 21:21:38 +00:00
Vitor Pamplona d42482ff56 Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7
Add I2P support with unified privacy routing
2026-05-19 16:56:54 -04:00
Claude 40995b9670 refactor(privacy): drop I2pType.INTERNAL — EXTERNAL is the permanent answer
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
2026-05-19 20:37:53 +00:00
Vitor Pamplona a8b6766f49 Merge pull request #2989 from vitorpamplona/claude/fix-zoomable-dialog-animation-3U3DK
Fix zoomed image dismiss animation to start from actual bounds
2026-05-19 15:59:18 -04:00
Claude 76c4771b58 fix(ui): start zoomable dialog close animation from zoomed bounds
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.
2026-05-19 19:42:07 +00:00
Vitor Pamplona 1f0eab5f1d Merge pull request #2985 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 15:36:57 -04:00
Vitor Pamplona bcb6f131dd Merge pull request #2988 from vitorpamplona/claude/review-bitcoin-blockchain-plan-6NGZ5
Plan: local Bitcoin headers explorer for NIP-03 OTS (revised 2026-05-19)
2026-05-19 15:36:42 -04:00
Claude 24cf3fdc5f docs: update onchain-zap + headers-explorer plans for NIP-BC inline SPV tags
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.
2026-05-19 19:17:36 +00:00
Vitor Pamplona 3f458f8316 Merge pull request #2987 from vitorpamplona/claude/sync-notification-filters-RbvPY
Align push notifications with in-app feed filtering
2026-05-19 14:55:27 -04:00
Claude d19bcf07b0 docs(quartz): add interop-test-vectors section to local-headers-explorer plan
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.
2026-05-19 18:35:30 +00:00
Claude c75c2013fb fix(notifications): resolve addressable events to their replaceable note
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).
2026-05-19 18:32:49 +00:00
Claude f3637dd7c1 docs(quartz): revise local-headers-explorer plan — SQLite, no bundle, OTS-only
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.
2026-05-19 18:14:30 +00:00
Claude 36e285f684 fix(notifications): WakeUp bypass + lookup hoist + comment cleanup
- 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.
2026-05-19 18:12:44 +00:00
Vitor Pamplona 8d4089e7e9 Merge pull request #2986 from vitorpamplona/claude/fix-illegal-argument-exception-V4AAM
Fix feed sorting race condition with snapshot-based comparator
2026-05-19 14:05:33 -04:00
Claude df87557ef3 fix: stable sort in ShortsFeedFilter to avoid TimSort contract crash
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.
2026-05-19 17:57:15 +00:00
Claude 49b244f026 fix: align push notifications with Notifications feed filter
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.
2026-05-19 17:26:33 +00:00
Crowdin Bot 8b3ed48fb6 New Crowdin translations by GitHub Action 2026-05-19 17:21:37 +00:00
Vitor Pamplona ed686b71ed Merge pull request #2981 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 13:20:01 -04:00
Vitor Pamplona 37ea5dea01 Merge pull request #2984 from vitorpamplona/claude/fix-zap-validation-6CbFV
NIP-57 Appendix F zap receipt validation with LNURL provider verification
2026-05-19 13:19:52 -04:00
Crowdin Bot 100533dfd7 New Crowdin translations by GitHub Action 2026-05-19 17:14:23 +00:00
Vitor Pamplona 88fd15eefc Merge pull request #2982 from vitorpamplona/claude/fix-video-notification-clickable-O811N
Fix playback notification tap target in warm-pool fast path
2026-05-19 13:12:37 -04:00
Vitor Pamplona 872533f102 Merge pull request #2983 from vitorpamplona/claude/fix-empty-transaction-chips-edyYE
Improve onchain transaction filter UX with separate empty states
2026-05-19 13:12:13 -04:00
Claude 0c2eecf62c fix: make playback notification tap open the note on warm-pool resume
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.
2026-05-19 16:33:55 +00:00
Claude 4346427e9f Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)
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.
2026-05-19 16:19:29 +00:00
Claude cb6e1fe696 fix: keep filter chips visible on empty onchain transactions list
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.
2026-05-19 16:18:33 +00:00