Commit Graph

13923 Commits

Author SHA1 Message Date
davotoula e131b0fec2 Show on chain zaps even if only reaction 2026-05-20 08:23:10 +02:00
Vitor Pamplona 4f094929d6 Merge pull request #3002 from vitorpamplona/claude/fix-locale-observable-by5fc
fix: observe locale in CalendarDateTimePickerButton
2026-05-19 20:10:17 -04:00
Vitor Pamplona d4f7f9f178 Merge pull request #3001 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:46:46 -04:00
Crowdin Bot d4f7f745af New Crowdin translations by GitHub Action 2026-05-19 23:34:03 +00:00
Vitor Pamplona 3a07b1879e Merge pull request #2996 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-19 19:32:34 -04:00
Crowdin Bot 9099fd4b84 New Crowdin translations by GitHub Action 2026-05-19 23:27:44 +00:00
Vitor Pamplona 6f4978f149 Merge pull request #3000 from vitorpamplona/claude/revert-commit-32f7bda-ObZBH
Remove I2P support and privacy routing abstraction
2026-05-19 19:25:50 -04:00
Claude ec3be14cf8 fix: observe locale in CalendarDateTimePickerButton
Use LocalConfiguration.current.locales[0] so the date formatter recomposes
when the device locale changes, satisfying the NonObservableLocale lint
check.
2026-05-19 23:24:57 +00:00
Vitor Pamplona 2ff67479a3 Merge pull request #2999 from vitorpamplona/claude/fix-notification-feed-bug-YNke0
Fix notification feed composition issue by removing CrossfadeIfEnabled
2026-05-19 19:16:13 -04:00
Claude 6a0801427b Revert "Merge pull request #2990 from vitorpamplona/claude/add-i2p-privacy-option-nK2X7"
This reverts commit d42482ff56, reversing
changes made to a8b6766f49.
2026-05-19 23:10:56 +00:00
Claude 9b94874d01 fix(notifications): drop crossfade around the card feed
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.
2026-05-19 23:00:50 +00:00
Vitor Pamplona 679c5a89e0 Merge pull request #2994 from vitorpamplona/claude/nip52-calendar-screens-yQc3j
Add NIP-52 calendar events support with UI and reminder system
2026-05-19 18:30:59 -04:00
Vitor Pamplona 297c2adffa Merge pull request #2998 from vitorpamplona/claude/fix-remember-return-type-l5SvB
Fix PrivacyOptionsScreen initialization with LaunchedEffect
2026-05-19 18:28:42 -04:00
Claude 952aa24001 fix(privacy): use LaunchedEffect instead of remember for VM reset
The remember(...) call returned Unit, which tripped the
RememberReturnType lint. Use LaunchedEffect, which is the
idiomatic Compose API for keyed side effects.
2026-05-19 22:27:13 +00:00
Vitor Pamplona bef3bd61c5 Merge pull request #2997 from vitorpamplona/claude/fix-completion-handler-exception-6WASO
Simplify Nip11Retriever by removing nested withContext
2026-05-19 18:23:42 -04:00
Claude 97a6d4bcbd fix(calendars): wire feeds into updateFeedsWith so new events stream in live + drop redundant Calendar Lists label
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.
2026-05-19 22:21:23 +00:00
Claude 62da33f02b fix(relay-info): switch to Dispatchers.IO around the whole executeAsync call
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.
2026-05-19 22:13:58 +00:00
Crowdin Bot 5045ef9dcf New Crowdin translations by GitHub Action 2026-05-19 22:08:49 +00:00
David Kaspar 3a84aa0168 Merge pull request #2991 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-20 00:07:19 +02:00
Vitor Pamplona 1909fe7e4c Merge pull request #2995 from vitorpamplona/claude/test-mls-reply-notifications-SnaIM
Add reply support for Marmot/MLS group messages
2026-05-19 18:07:17 -04:00
Claude c7a4796b25 refactor(marmot): audit follow-ups on MLS reply paths
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.
2026-05-19 22:04:14 +00:00
Claude cc78744a10 fix(calendars): tapping a Calendar List opens the dedicated detail screen, not the generic thread view
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.
2026-05-19 21:58:35 +00:00
Claude 382729520c chore(commons): regenerate Material Symbols font subset to include EventAvailable
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.
2026-05-19 21:58:35 +00:00
Claude 1b563fb3f0 fix(calendars): clear EOSE cursor on list switch so evicted notes get re-fetched
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.
2026-05-19 21:58:35 +00:00
Claude d96209b20d fix(calendars): top-bar filter switch resets scroll; week/month/day headers scroll with the disappearing bar
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.
2026-05-19 21:58:34 +00:00
Claude 638e22c262 fix(calendars): tighten ReactionsRow spacing on the detail screen
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.
2026-05-19 21:58:34 +00:00
Claude fc1da467e7 feat(calendars): user-search participant picker + hide createdAt on cards
- 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.
2026-05-19 21:58:34 +00:00
Claude 5dfabf667f feat(calendars): route URL locations to the browser, not the maps app
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.
2026-05-19 21:58:34 +00:00
Claude 0dea41602a fix(calendars): apply scaffold padding so month/week/day headers and collections list don't render behind the top app bar
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).
2026-05-19 21:58:34 +00:00
Claude 5116bc21ae refactor(calendars): extract DAL to commons + a11y + inline-FQN cleanup
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).
2026-05-19 21:58:34 +00:00
Claude ab5d884b34 feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid
- 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.
2026-05-19 21:58:34 +00:00
Claude 971e9e4846 feat(calendars): reactions/zaps row, multi-day events, swipe nav, prefetch
- 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
2026-05-19 21:58:34 +00:00
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