diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt index a6d06cae6a..c496d512bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt @@ -42,22 +42,29 @@ object CalendarReminderNotifier { private const val REMINDER_NOT_ID_BASE = 0x80000 /** - * @param eventId the appointment's event id — used to derive a stable notification id so a - * second reminder for the same event collapses rather than stacking. - * @param title the appointment title (or a fallback string). - * @param body a short pre-formatted body, e.g. "Starts in 15 minutes". + * @param eventId the appointment's event id — used to derive a stable notification id so a + * second reminder for the same event collapses rather than stacking. + * @param title the appointment title (or a fallback string). + * @param body a short pre-formatted body, e.g. "Starts in 15 minutes". + * @param deepLink a `nostr:naddr…` URI for the calendar event. Tapping the notification + * hands this to MainActivity, which routes it via `uriToRoute` → the + * calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent + * branches in RouteMaker resolve to Route.CalendarEventDetail). */ fun notifyReminder( context: Context, eventId: String, title: String, body: String, + deepLink: String, ) { ensureChannel(context) val notId = idFor(eventId) val channelId = stringRes(context, R.string.calendar_reminder_channel_id) val tapIntent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = android.net.Uri.parse(deepLink) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } val tapPendingIntent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt index f5372eb4f6..eb78d56322 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt @@ -39,7 +39,16 @@ class CalendarReminderStore( private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - fun wasNotified(eventId: String): Boolean = prefs.contains(keyFor(eventId)) + /** + * Returns true when we've previously notified for this exact event-start pairing. If the + * author updates the appointment to a new start time, the stored value won't match and + * we'll fire a fresh reminder for the new time — that's the desired behaviour: a moved + * meeting shouldn't be silently skipped. + */ + fun wasNotified( + eventId: String, + eventStartSeconds: Long, + ): Boolean = prefs.getLong(keyFor(eventId), Long.MIN_VALUE) == eventStartSeconds fun markNotified( eventId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index bb663b355f..0ef9a6866c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -80,7 +80,9 @@ class CalendarReminderWorker( ?: return@forEach if (start !in now..windowEnd) return@forEach - if (store.wasNotified(eventId)) return@forEach + // Keyed on (eventId, start) so a moved appointment re-fires when the new start + // enters the lead window — the old notification stays valid in the system tray. + if (store.wasNotified(eventId, start)) return@forEach val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title) val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt() @@ -90,7 +92,11 @@ class CalendarReminderWorker( R.string.calendar_reminder_body, minutesAway, ) - CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body) + val deepLink = + "nostr:" + + com.vitorpamplona.quartz.nip19Bech32.entities.NAddress + .create(targetAddress.kind, targetAddress.pubKeyHex, targetAddress.dTag, null) + CalendarReminderNotifier.notifyReminder(applicationContext, eventId, title, body, deepLink) store.markNotified(eventId, start) Log.d(TAG) { "Notified $eventId (starts in ${minutesAway}m)" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index d38d7726b6..bf04b51fc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -149,6 +149,18 @@ fun routeForInner( Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) } + // Calendar appointments route to their dedicated detail screen rather than the generic + // Route.Note that AddressableEvent would fall through to — without this the notification + // tap and `nostr:naddr…` deep links land on the bare note view instead of the calendar + // detail with RSVPs, participants, and the "in calendars" list. + is com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -> { + Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } + + is com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -> { + Route.CalendarEventDetail(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag()) + } + is GiftWrapEvent -> { noteEvent.innerEventId?.let { routeFor(LocalCache.getOrCreateNote(it), loggedIn) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt index d5f220aa00..c197dcf54e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarCollectionsView.kt @@ -95,7 +95,7 @@ private fun CollectionsBody( val items by loaded.feed.collectAsStateWithLifecycle() LazyColumn(modifier = Modifier.fillMaxSize()) { items(items.list, key = { it.idHex }) { note -> - CalendarCollectionCard(note, nav) + CalendarCollectionCard(note, accountViewModel, nav) } } } @@ -117,6 +117,7 @@ private fun EmptyCollections() { @Composable fun CalendarCollectionCard( note: Note, + accountViewModel: AccountViewModel, nav: INav, ) { val event = note.event as? CalendarEvent ?: return @@ -135,8 +136,14 @@ fun CalendarCollectionCard( colors = CardDefaults.elevatedCardColors(), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), ) { + // Author header matches every other social card in the app. + com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader( + baseNote = note, + accountViewModel = accountViewModel, + nav = nav, + ) Row( - modifier = Modifier.padding(14.dp), + modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 2ba7427d4b..b6f5600b9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader import com.vitorpamplona.quartz.utils.TimeUtils import java.time.Instant import java.time.ZoneId @@ -92,8 +93,13 @@ fun CalendarEventListCard( colors = CardDefaults.elevatedCardColors(), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp), ) { + // Author header matches the picture-feed / shorts card shape: avatar + display name + + // time-ago at the top of every social card in the app. Without this, calendar cards + // looked alien next to the rest of the feed. + UserCardHeader(baseNote = note, accountViewModel = accountViewModel, nav = nav) + Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.Top, ) { CalendarDateBadge(view.startSeconds) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index 4cd10bc0d6..9ca81ebab5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -295,14 +295,14 @@ private fun EventBody( if (participants.isNotEmpty()) { HorizontalDivider() - ParticipantsSection(participants) + ParticipantsSection(participants, accountViewModel, nav) } HorizontalDivider() - RsvpsSection(targetAddress) + RsvpsSection(targetAddress, accountViewModel, nav) HorizontalDivider() - InCalendarsSection(targetAddress, nav) + InCalendarsSection(targetAddress, accountViewModel, nav) Spacer(modifier = Modifier.height(24.dp)) } @@ -367,26 +367,30 @@ private fun LocationRow(location: String) { } @Composable -private fun ParticipantsSection(participants: List) { - Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { +private fun ParticipantsSection( + participants: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionTitle(stringRes(R.string.calendar_participants_section, participants.size)) participants.forEach { p -> - Text( - text = formatPubKeyShort(p.pubKey), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + UserRow(p.pubKey, accountViewModel, nav, trailing = null) } } } @Composable -private fun RsvpsSection(targetAddress: Address) { - val rsvps = remember(targetAddress) { findRsvpsFor(targetAddress) } +private fun RsvpsSection( + targetAddress: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Reactively re-scan when LocalCache emits new bundles. Without this, RSVPs that arrive + // from relays while the screen is open don't show up until the user leaves and returns. + val rsvps by rememberRsvpsFor(targetAddress) - Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionTitle(stringRes(R.string.calendar_rsvp_section, rsvps.size)) if (rsvps.isEmpty()) { Text( @@ -397,18 +401,11 @@ private fun RsvpsSection(targetAddress: Address) { return@Column } rsvps.forEach { rsvp -> - val statusLabel = - when (rsvp.status()) { - RSVPStatusTag.STATUS.ACCEPTED -> stringRes(R.string.calendar_rsvp_going_prefixed) - RSVPStatusTag.STATUS.TENTATIVE -> stringRes(R.string.calendar_rsvp_maybe_prefixed) - RSVPStatusTag.STATUS.DECLINED -> stringRes(R.string.calendar_rsvp_not_going_prefixed) - null -> "—" - } - Text( - text = "$statusLabel · ${formatPubKeyShort(rsvp.pubKey)}", - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + UserRow( + pubKey = rsvp.pubKey, + accountViewModel = accountViewModel, + nav = nav, + trailing = { RsvpStatusBadge(rsvp.status()) }, ) } } @@ -417,11 +414,12 @@ private fun RsvpsSection(targetAddress: Address) { @Composable private fun InCalendarsSection( targetAddress: Address, + accountViewModel: AccountViewModel, nav: INav, ) { - val calendars = remember(targetAddress) { findCalendarsContaining(targetAddress) } + val calendars by rememberCalendarsContaining(targetAddress) - Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionTitle(stringRes(R.string.calendar_event_in_calendars, calendars.size)) if (calendars.isEmpty()) { Text( @@ -432,10 +430,7 @@ private fun InCalendarsSection( return@Column } calendars.forEach { calendar -> - Text( - text = calendar.title() ?: stringRes(R.string.calendar_untitled), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + Row( modifier = Modifier .fillMaxWidth() @@ -448,13 +443,99 @@ private fun InCalendarsSection( ), ) }.padding(vertical = 4.dp), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + com.vitorpamplona.amethyst.ui.note.ClickableUserPicture( + baseUserHex = calendar.pubKey, + size = com.vitorpamplona.amethyst.ui.theme.Size30dp, + accountViewModel = accountViewModel, + ) + Text( + text = calendar.title() ?: stringRes(R.string.calendar_untitled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } } } +/** + * Shared social-row layout: avatar (clickable → profile), display name, optional trailing slot + * for things like RSVP badges. Matches the visual language of every other user-list across the + * app. + */ +@Composable +private fun UserRow( + pubKey: String, + accountViewModel: AccountViewModel, + nav: INav, + trailing: (@Composable () -> Unit)?, +) { + com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms + .LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + com.vitorpamplona.amethyst.ui.note.ClickableUserPicture( + baseUserHex = pubKey, + size = com.vitorpamplona.amethyst.ui.theme.Size35dp, + accountViewModel = accountViewModel, + onClick = { + nav.nav( + com.vitorpamplona.amethyst.ui.navigation.routes.Route + .Profile(pubKey), + ) + }, + ) + if (user != null) { + com.vitorpamplona.amethyst.ui.note.UsernameDisplay( + baseUser = user, + weight = Modifier.weight(1f), + accountViewModel = accountViewModel, + ) + } else { + // LoadUser is still resolving — show the npub-style fallback so the row + // doesn't visibly collapse. + Text( + text = formatPubKeyShort(pubKey), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + trailing?.invoke() + } + } +} + +@Composable +private fun RsvpStatusBadge(status: RSVPStatusTag.STATUS?) { + val (label, color) = + when (status) { + RSVPStatusTag.STATUS.ACCEPTED -> + stringRes(R.string.calendar_rsvp_going_prefixed) to MaterialTheme.colorScheme.primary + RSVPStatusTag.STATUS.TENTATIVE -> + stringRes(R.string.calendar_rsvp_maybe_prefixed) to MaterialTheme.colorScheme.tertiary + RSVPStatusTag.STATUS.DECLINED -> + stringRes(R.string.calendar_rsvp_not_going_prefixed) to MaterialTheme.colorScheme.error + null -> "—" to MaterialTheme.colorScheme.onSurfaceVariant + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = color, + fontWeight = FontWeight.SemiBold, + ) +} + @Composable private fun SectionTitle(text: String) { Text( @@ -469,9 +550,26 @@ private fun SectionTitle(text: String) { private fun formatPubKeyShort(pubKey: String): String = if (pubKey.length <= 16) pubKey else pubKey.take(8) + "…" + pubKey.takeLast(8) /** - * Scans LocalCache for kind-31925 RSVPs that a-tag [targetAddress]. Snapshotted at call time — - * see the class kdoc for the reactivity trade-off. + * Reactive scan of [LocalCache] for kind-31925 RSVPs that a-tag [targetAddress]. Re-runs on + * every new-event bundle so RSVPs that arrive while the screen is open appear without a manual + * refresh. The scan is O(addressables) which is bounded by the relay subscription. */ +@Composable +private fun rememberRsvpsFor(targetAddress: Address): androidx.compose.runtime.State> = + androidx.compose.runtime.produceState(initialValue = findRsvpsFor(targetAddress), targetAddress) { + LocalCache.live.newEventBundles.collect { + value = findRsvpsFor(targetAddress) + } + } + +@Composable +private fun rememberCalendarsContaining(targetAddress: Address): androidx.compose.runtime.State> = + androidx.compose.runtime.produceState(initialValue = findCalendarsContaining(targetAddress), targetAddress) { + LocalCache.live.newEventBundles.collect { + value = findCalendarsContaining(targetAddress) + } + } + private fun findRsvpsFor(targetAddress: Address): List = LocalCache.addressables .filterIntoSet { _, note -> @@ -480,9 +578,6 @@ private fun findRsvpsFor(targetAddress: Address): List = }.mapNotNull { it.event as? CalendarRSVPEvent } .sortedByDescending { it.createdAt } -/** - * Scans LocalCache for kind-31924 calendars whose `a` tags include [targetAddress]. - */ private fun findCalendarsContaining(targetAddress: Address): List = LocalCache.addressables .filterIntoSet { _, note ->