diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt index ac30218f78..49e6eaebaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CalendarRsvpRender.kt @@ -20,40 +20,57 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent /** * Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is - * in the cache, and renders the shared commons [CalendarRsvpCard]. - */ -@Composable -fun RenderCalendarRSVPEvent( - note: Note, - accountViewModel: AccountViewModel, - nav: INav, -) { - val event = note.event as? CalendarRSVPEvent ?: return - - LoadAppointmentBehind(event, accountViewModel) - - CalendarRsvpCard(event) -} - -/** - * Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the - * two together. + * in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment merged + * into its frame — "going" is only interesting next to *what*. Until the appointment loads the + * card shows its status strip over a "loading" line. * * [LoadAddressableNote] creates the [com.vitorpamplona.amethyst.commons.model.AddressableNote] in * `LocalCache` — a shell with a null event if we have never seen the appointment — and - * [EventFinderFilterAssemblerSubscription] then asks relays for it: `filterMissingAddressables` + * [WatchNoteEvent]'s event-finder subscription then asks relays for it: `filterMissingAddressables` * picks up exactly those addressables whose `event == null` and queries the address author's * outbox relays plus any stored hints. * @@ -67,15 +84,214 @@ fun RenderCalendarRSVPEvent( * scrolls away or the app backgrounds. */ @Composable -private fun LoadAppointmentBehind( - event: CalendarRSVPEvent, +fun RenderCalendarRSVPEvent( + note: Note, accountViewModel: AccountViewModel, + nav: INav, ) { - val address = remember(event) { event.calendarEventAddress() } ?: return + val event = note.event as? CalendarRSVPEvent ?: return + val address = remember(event) { event.calendarEventAddress() } + + if (address == null) { + CalendarRsvpCard(event) + return + } LoadAddressableNote(address) { appointment -> - if (appointment != null) { - EventFinderFilterAssemblerSubscription(appointment, accountViewModel) + if (appointment == null) { + CalendarRsvpCard(event) + } else { + // WatchNoteEvent subscribes the event finder only while the appointment is missing. + // Once it is here the card has everything it draws; a standing subscription would + // keep asking relays for the appointment's reactions and zaps on every RSVP in the feed. + WatchNoteEvent( + baseNote = appointment, + onNoteEventFound = { RsvpWithAppointment(event, appointment, accountViewModel, nav) }, + onBlank = { CalendarRsvpCard(event) }, + accountViewModel = accountViewModel, + ) } } } + +/** + * The appointment drawn *inside* the RSVP's frame, under its status strip: cover image full + * width, then the same date badge and text lines the calendar list uses. No author header — the + * post already says who answered — and no card of its own, so there is one frame, not two. + * "In 3 days" moves up into the strip, next to the answer it qualifies. + */ +@Composable +private fun RsvpWithAppointment( + event: CalendarRSVPEvent, + appointment: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val view = appointment.appointmentView() + if (view == null) { + CalendarRsvpCard(event) + return + } + + val context = LocalContext.current + val appointmentEvent = appointment.event + val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) } + val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id) + val route = remember(appointment) { detailRouteFor(appointment) } + + CalendarRsvpCard( + event = event, + statusDetail = relative, + onClick = { nav.nav(route) }, + ) { + val image = view.image + if (!image.isNullOrBlank()) { + MyAsyncImage( + imageUrl = image, + contentDescription = view.title, + contentScale = ContentScale.Crop, + mainImageModifier = CoverImageModifier, + loadedImageModifier = Modifier, + accountViewModel = accountViewModel, + onLoadingBackground = { Box(CoverImageModifier) }, + onError = null, + ) + } + + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.Top, + ) { + CalendarDateBadge(view.startSeconds) + Spacer(modifier = Modifier.size(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + CalendarAppointmentLines(view, range, relative = null) + } + } + } +} + +private val CoverImageModifier = Modifier.fillMaxWidth().aspectRatio(2f) + +// -------------------------------------------------------------------------------------------- +// Previews +// -------------------------------------------------------------------------------------------- + +private const val PREVIEW_HOST = "c07b08396b7bffdb659f862dd7ead57ae169caea65ed573d161e13c1cd6d490c" +private const val PREVIEW_FRIEND = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + +private fun previewConference() = + CalendarTimeSlotEvent( + id = "5c1a0e3b8f2d4a6e9b7c1d3f5a7e9c2b4d6f8a1c3e5b7d9f2a4c6e8b1d3f5a7e", + pubKey = PREVIEW_HOST, + createdAt = 1788000000, + tags = + arrayOf( + arrayOf("d", "nostr-conference-2026"), + arrayOf("title", "Nostr Conference 2026"), + arrayOf("image", "https://image.nostr.build/conference-cover.jpg"), + arrayOf("start", "1791622800"), + arrayOf("end", "1791655200"), + arrayOf("start_tzid", "Europe/Lisbon"), + arrayOf("location", "Lisbon, Portugal"), + ), + content = "Two days of talks, workshops and hacking on the protocol.", + sig = "0".repeat(128), + ) + +private fun previewMeetup() = + CalendarDateSlotEvent( + id = "8e2c4a6f1b3d5e7a9c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d8f1a", + pubKey = PREVIEW_HOST, + createdAt = 1788000000, + tags = + arrayOf( + arrayOf("d", "bitcoin-meetup"), + arrayOf("title", "Bitcoin Meetup: open discussion"), + arrayOf("start", "2026-10-24"), + arrayOf("location", "Restaurant Bjørk, Bodø"), + ), + content = "", + sig = "0".repeat(128), + ) + +private fun previewRsvp( + id: String, + target: Address, + status: RSVPStatusTag.STATUS, + comment: String, +) = CalendarRSVPEvent( + id = id, + pubKey = PREVIEW_FRIEND, + createdAt = 1789000000, + tags = + arrayOf( + arrayOf("d", "rsvp-$id"), + arrayOf("a", target.toValue()), + status.toTagArray(), + arrayOf("p", target.pubKeyHex), + ), + content = comment, + sig = "0".repeat(128), +) + +@Composable +private fun PreviewRsvpOf( + appointment: Event, + rsvp: CalendarRSVPEvent, +) { + LocalCache.justConsume(appointment, null, true) + LocalCache.justConsume(rsvp, null, true) + val note = LocalCache.getOrCreateNote(rsvp.id) + + ThemeComparisonColumn { + RenderCalendarRSVPEvent(note, mockAccountViewModel(), EmptyNav()) + } +} + +@Composable +@Preview +fun RenderCalendarRsvpGoingPreview() { + val conference = previewConference() + PreviewRsvpOf( + conference, + previewRsvp( + id = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", + target = conference.address(), + status = RSVPStatusTag.STATUS.ACCEPTED, + comment = "See you all there!", + ), + ) +} + +@Composable +@Preview +fun RenderCalendarRsvpMaybeNoImagePreview() { + val meetup = previewMeetup() + PreviewRsvpOf( + meetup, + previewRsvp( + id = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1", + target = meetup.address(), + status = RSVPStatusTag.STATUS.TENTATIVE, + comment = "", + ), + ) +} + +/** The appointment has not arrived yet: the status strip over the loading line. */ +@Composable +@Preview +fun CalendarRsvpCardLoadingPreview() { + val rsvp = + previewRsvp( + id = "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2", + target = Address(CalendarTimeSlotEvent.KIND, PREVIEW_HOST, "not-fetched-yet"), + status = RSVPStatusTag.STATUS.ACCEPTED, + comment = "Who else is coming?", + ) + + ThemeComparisonColumn { + CalendarRsvpCard(rsvp) + } +} 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 bb000cd595..16366ee7b2 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 @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -75,6 +76,8 @@ fun CalendarEventListCard( accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, + // Drawn inside the card under the appointment, for lenses that annotate it (who's going). + footer: (@Composable () -> Unit)? = null, ) { val view = note.appointmentView() ?: return val context = LocalContext.current @@ -118,51 +121,7 @@ fun CalendarEventListCard( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - view.title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - range?.let { - Text( - text = it, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - relative?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - view.location?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.size(4.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + CalendarAppointmentLines(view, range, relative) val image = view.image val summary = view.summary if (!image.isNullOrBlank()) { @@ -189,11 +148,71 @@ fun CalendarEventListCard( } } } + + footer?.invoke() + } +} + +/** + * Title, time range, relative time and location of an appointment — the text half of every + * calendar row. Shared by the calendar list card and the feed's RSVP card so the two cannot + * disagree about how an event reads. Pass a null [relative] where the caller shows it elsewhere. + */ +@Composable +internal fun CalendarAppointmentLines( + view: CalendarAppointmentView, + range: String?, + relative: String?, +) { + view.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + range?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + relative?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + view.location?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.size(4.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } @Composable -private fun CalendarDateBadge(startSeconds: Long?) { +internal fun CalendarDateBadge(startSeconds: Long?) { if (startSeconds == null) { Box( modifier = Modifier.size(width = 52.dp, height = 60.dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt new file mode 100644 index 0000000000..8e643aeb96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.AppointmentAttendance +import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarEmptyState +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_count +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_subtitle +import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_title +import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.types.UserGallery +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** Faces drawn on a row before the rest collapse into "+N". */ +private const val ATTENDEE_FACES = 6 + +/** + * Keeps a relay request from growing without bound when the list follows people with a long RSVP + * history of appointments this device has never seen. + */ +private const val MAX_APPOINTMENTS_TO_FETCH = 100 + +/** + * The upcoming events the people in the selected list said they're going to, soonest first, each + * with the faces of who's going. + * + * The RSVPs come in on the screen's calendar subscription (it asks the list's authors for their + * kind-31925s). The appointments they point at are usually published by someone else — a + * conference organizer the viewer does not follow — so the ones the cache is missing are fetched + * here, by address, through the per-note event finder. + */ +@Composable +fun CalendarFollowsGoingView( + model: CalendarsViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val state by model.followsGoing.collectAsStateWithLifecycle() + val going = state + if (going == null) { + Box(Modifier.fillMaxSize()) + return + } + + FetchMissingAppointments(going.unresolved, accountViewModel) + + if (going.upcoming.isEmpty()) { + CalendarEmptyState( + title = stringRes(Res.string.calendar_follows_going_empty_title), + subtitle = stringRes(Res.string.calendar_follows_going_empty_subtitle), + ) + return + } + + LazyColumn( + state = model.followsGoingListState, + contentPadding = rememberFeedContentPadding(FeedPadding), + modifier = Modifier.fillMaxSize(), + ) { + items(going.upcoming, key = { it.appointment.idHex }) { entry -> + CalendarEventListCard( + note = entry.appointment, + accountViewModel = accountViewModel, + nav = nav, + footer = { AttendeesRow(entry, accountViewModel, nav) }, + ) + } + } +} + +@Composable +private fun FetchMissingAppointments( + unresolved: List, + accountViewModel: AccountViewModel, +) { + unresolved.take(MAX_APPOINTMENTS_TO_FETCH).forEach { appointment -> + key(appointment.idHex) { + EventFinderFilterAssemblerSubscription(appointment, accountViewModel) + } + } +} + +@Composable +private fun AttendeesRow( + entry: AppointmentAttendance, + accountViewModel: AccountViewModel, + nav: INav, +) { + val shown = remember(entry) { entry.attendees.take(ATTENDEE_FACES).map(LocalCache::getOrCreateUser) } + val total = entry.attendees.size + + Row( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserGallery(shown, total) { user -> + UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav) + } + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = pluralStringRes(Res.plurals.calendar_follows_going_count, total, total), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt index 5b019c2ac3..1936c6e600 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription +import kotlinx.coroutines.launch @Composable fun CalendarsScreen( @@ -69,8 +71,10 @@ fun CalendarsScreen( // itself goes. See [CalendarsViewModel]. val model: CalendarsViewModel = viewModel() model.init(accountViewModel.userProfile().pubkeyHex, feedState) + model.bindAttendeeFilter(accountViewModel.account.liveCalendarsFollowLists, accountViewModel.account.hiddenUsers.flow) val filterDTag by model.filterDTag.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() DisappearingScaffold( isInvertedLayout = false, @@ -80,19 +84,31 @@ fun CalendarsScreen( onViewModeChange = { model.viewMode = it }, accountViewModel = accountViewModel, nav = nav, - trailing = { - CalendarFilterChip( - selectedDTag = filterDTag, - onSelect = model::selectCalendar, - model = model, - ) - }, + // The membership filter narrows the viewer's own calendars; the follows lens is + // built from other people's RSVPs, so the chip would claim a filter it ignores. + trailing = + if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) { + null + } else { + { + CalendarFilterChip( + selectedDTag = filterDTag, + onSelect = model::selectCalendar, + model = model, + ) + } + }, ) }, bottomBar = { AppBottomBar(Route.Calendars, nav, accountViewModel) { route -> if (route == Route.Calendars) { - feedState.sendToTop() + if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) { + // Built from RSVPs, not the feed, so the feed's scroll signal never reaches it. + scope.launch { model.followsGoingListState.animateScrollToItem(0) } + } else { + feedState.sendToTop() + } } else { nav.navBottomBar(route) } @@ -109,6 +125,7 @@ fun CalendarsScreen( Column(modifier = Modifier.fillMaxSize()) { when (model.viewMode) { CalendarsViewMode.FEED -> CalendarFeedView(feedState, model, accountViewModel, nav) + CalendarsViewMode.FOLLOWS_GOING -> CalendarFollowsGoingView(model, accountViewModel, nav) CalendarsViewMode.MONTH -> CalendarMonthView(model, accountViewModel, nav) CalendarsViewMode.WEEK -> CalendarWeekView(model, accountViewModel, nav) CalendarsViewMode.DAY -> CalendarDayView(model, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt index 3ad0fc5182..99c31d4e52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsTopBar.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -84,17 +85,12 @@ fun CalendarsTopBar( ) } - Row( + CalendarsViewModeTabs( + current = viewMode, + onChange = onViewModeChange, + trailing = trailing, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, - ) { - CalendarsViewModeTabs( - current = viewMode, - onChange = onViewModeChange, - modifier = Modifier.weight(1f), - ) - trailing?.invoke() - } + ) } } @@ -120,13 +116,19 @@ private fun CalendarsTopNavFilterBar( private fun CalendarsViewModeTabs( current: CalendarsViewMode, onChange: (CalendarsViewMode) -> Unit, + trailing: (@Composable () -> Unit)?, modifier: Modifier = Modifier, ) { + // The trailing control scrolls with the lenses instead of being pinned beside them: pinned, + // it took its width out of the scrolling part, and once the lenses outgrew what was left + // (on an 800px tablet, after "Friends going" joined them) the last lens sat half-hidden + // against it with nothing to say the row scrolls. A chip cut by the screen edge does. Row( modifier = modifier .horizontalScroll(rememberScrollState()) .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { CalendarsViewMode.entries.forEach { mode -> FilterChip( @@ -143,5 +145,6 @@ private fun CalendarsViewModeTabs( shape = MaterialTheme.shapes.small, ) } + trailing?.invoke() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt index fe401d2b98..5b7d95610d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt @@ -31,31 +31,49 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.feeds.FeedState +import com.vitorpamplona.amethyst.commons.feeds.FilterByListParams +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing import com.vitorpamplona.amethyst.commons.model.nip52Calendar.MonthGridBarSegment +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeMonthGridBars import com.vitorpamplona.amethyst.commons.model.nip52Calendar.groupByDayKeyExpanded +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavFilter import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarsViewMode +import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.LocalDate import java.time.YearMonth +import java.util.concurrent.ConcurrentHashMap /** * Everything the calendar screen is *looking at*: which lens is open, which calendar the feed is @@ -260,6 +278,122 @@ class CalendarsViewModel : ViewModel() { UpcomingPastSplit(emptyList(), emptyList()), ) + // ------------------------------------------------------------------------------------------ + // Where the people in the selected list are going + // ------------------------------------------------------------------------------------------ + + /** + * What decides whose RSVPs count: the screen's top-nav list and the account's mute state, + * handed over as the account's own flows so a list switch or a new mute re-runs the fold. + */ + private data class AttendeeInputs( + val lists: StateFlow, + val hidden: StateFlow, + ) + + private val attendeeInputs = MutableStateFlow(null) + + /** + * Every kind-31925 note seen while this screen lives, held strongly. + * + * `LocalCache.addressables` keeps its values by weak reference, and nothing else in the app + * references an RSVP by someone else: the calendar feed lists appointments, not answers. So + * the RSVPs the screen's subscription pulls in while the user is on another lens could be + * collected before they ever open "Friends going" — and the subscription's EOSE cursor has + * already moved past them, so they would not be asked for again. Pinning them here, from the + * moment the screen binds, is what lets the lens be opened later and still see them. They + * are released with the screen's back-stack entry. Kind-31925 events are a few hundred bytes. + */ + private val heldRsvps: MutableSet = ConcurrentHashMap.newKeySet() + + private var rsvpPinner: Job? = null + + /** Idempotent — the screen calls it on every composition. */ + fun bindAttendeeFilter( + lists: StateFlow, + hidden: StateFlow, + ) { + val bound = AttendeeInputs(lists, hidden) + if (attendeeInputs.value != bound) attendeeInputs.value = bound + + if (rsvpPinner == null) { + rsvpPinner = + viewModelScope.launch(Dispatchers.Default) { + heldRsvps.addAll(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND)) + LocalCache + .observeNewEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))) + .collect { rsvp -> LocalCache.getAddressableNoteIfExists(rsvp.address())?.let(heldRsvps::add) } + } + } + } + + /** + * The upcoming appointments the people in the selected list said they're going to, soonest + * first, plus the appointments they RSVP'd to that are not in the cache yet (for the lens to + * fetch). Null until the first fold lands, so the lens can tell "loading" from "nobody". + * + * Wakes on one cache observer covering both kinds it reads: RSVPs, and appointments, because + * an RSVP usually lands before the appointment it answers — the host is often someone the + * viewer does not follow — and the row can only be placed once the appointment's date is + * known. The observer is only a wake-up signal; each fold re-reads the RSVPs from the + * addressable cache's kind index, which holds exactly the latest version of each one. + * + * That replaced `observeEvents`, whose seed walks every regular note in the cache (its + * `filter()` scans `notes` whatever the kinds) and which then copies its whole list and id + * set on every insert — quadratic on the burst of RSVPs a list switch pulls in, each copy + * followed by a full re-fold. Here the cheap part (combining the signal with the list and + * mute state) runs per event, and [conflate] lets the fold skip every signal that arrived + * while the previous fold was running. + * + * `now` is read on each fold rather than ticking on a timer: an event that ends while the lens + * is open lingers until the next RSVP or appointment arrives, which is harmless. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val followsGoing: StateFlow = + attendeeInputs + .filterNotNull() + .flatMapLatest { (lists, hidden) -> + combine( + LocalCache + .observeNewEvents(Filter(kinds = FOLLOWS_GOING_KINDS)) + .conflate() + .map { } + .onStart { emit(Unit) }, + lists, + hidden, + ) { _, list, hiddenUsers -> + FilterByListParams.create(list, hiddenUsers) + }.conflate() + .map(::foldFollowsGoing) + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + private fun foldFollowsGoing(params: FilterByListParams): FollowsGoing { + // The index scan sees an RSVP the pinner has not reached yet; the pin set holds the rest. + val rsvpNotes = LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND) + heldRsvps.addAll(rsvpNotes) + val rsvps = rsvpNotes.mapNotNull { it.event as? CalendarRSVPEvent } + + // Relays only matter to a relay-scoped list, so the others skip the lookup. + val relaysOf: (CalendarRSVPEvent) -> List = + if (params.followLists is RelayTopNavFilter) { + val byId = rsvpNotes.associateBy({ it.event?.id }, { it.relays }) + ({ byId[it.id] ?: emptyList() }) + } else { + ({ emptyList() }) + } + + return computeFollowsGoing( + rsvps = rsvps, + isAttendee = { params.match(it, relaysOf(it)) }, + appointmentFor = LocalCache::getOrCreateAddressableNote, + nowSeconds = TimeUtils.now(), + isAppointmentVisible = { params.isHiddenList || params.isNotHidden(it.pubKeyHex) }, + ) + } + + val followsGoingListState = LazyListState() + // ------------------------------------------------------------------------------------------ // Paging // ------------------------------------------------------------------------------------------ @@ -304,6 +438,8 @@ class CalendarsViewModel : ViewModel() { * list, short enough that a backgrounded screen stops holding observers open. */ private const val STOP_TIMEOUT_MS = 5_000L + + private val FOLLOWS_GOING_KINDS = CalendarAppointmentKinds + CalendarRSVPEvent.KIND } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index 288ea25da9..d0c9e9c634 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.filterIntoSet +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.isLatestAnswerIn +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.latestRsvpAnswers import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.model.Account @@ -45,12 +47,19 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent @@ -87,6 +96,7 @@ class HomeNewThreadFeedFilter( AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND, EntityRatingEvent.KIND, + CalendarRSVPEvent.KIND, ) } @@ -105,18 +115,19 @@ class HomeNewThreadFeedFilter( override fun feed(): List { val filterParams = buildFilterParams(account) val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value) + val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() } val notes = LocalCache.notes.filterIntoSet { _, note -> // Avoids processing addressables twice. - (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds) + (note.event?.kind ?: 99999) < 10000 && acceptableEvent(note, filterParams, disabledKinds, latestRsvps) } val longFormNotes = LocalCache.addressables.filterIntoSet( kinds = ADDRESSABLE_KINDS, ) { _, note -> - acceptableEvent(note, filterParams, disabledKinds) + acceptableEvent(note, filterParams, disabledKinds, latestRsvps) } return sort(notes + longFormNotes) @@ -124,12 +135,28 @@ class HomeNewThreadFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + /** + * The additive merge only ever adds, so a "Going" card already on screen would outlive the + * author changing their mind: when a batch carries RSVPs, the old list first loses every + * RSVP that is no longer its author's latest answer. + */ + override fun updateListWith( + oldList: List, + newItems: Set, + ): List { + if (newItems.none { it.event is CalendarRSVPEvent }) return super.updateListWith(oldList, newItems) + val latest = cachedRsvpAnswers() + val stillCurrent = oldList.filter { (it.event as? CalendarRSVPEvent)?.isLatestAnswerIn(latest) ?: true } + return super.updateListWith(stillCurrent, newItems) + } + private fun innerApplyFilter(collection: Collection): Set { val filterParams = buildFilterParams(account) val disabledKinds = HomeFeedType.disabledKinds(account.settings.enabledHomeFeedTypes.value) + val latestRsvps = lazy(LazyThreadSafetyMode.NONE) { cachedRsvpAnswers() } return collection.filterTo(HashSet()) { - acceptableEvent(it, filterParams, disabledKinds) + acceptableEvent(it, filterParams, disabledKinds, latestRsvps) } } @@ -137,6 +164,7 @@ class HomeNewThreadFeedFilter( it: Note, filterParams: FilterByListParams, disabledKinds: Set, + latestRsvps: Lazy, CalendarRSVPEvent>>, ): Boolean { val noteEvent = it.event ?: return false if (noteEvent.kind in disabledKinds) return false @@ -174,7 +202,8 @@ class HomeNewThreadFeedFilter( noteEvent is AttestorRecommendationEvent || noteEvent is AttestorProficiencyEvent || // A rating with nothing to point at cannot be rendered. - (noteEvent is EntityRatingEvent && noteEvent.hasTarget()) + (noteEvent is EntityRatingEvent && noteEvent.hasTarget()) || + noteEvent.isGoingRsvp(latestRsvps) ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() @@ -190,3 +219,20 @@ class HomeNewThreadFeedFilter( } }.sortedByDefaultFeedOrder() } + +/** + * Only "going" RSVPs reach the home feed: the point is to see where your people are going. + * Maybes and declines are still visible on the appointment itself, but as posts they are noise. + * And only the author's current answer: someone who said "going" and later "can't go" under a + * new d tag still has the old RSVP live, and it must not keep saying they are going. + */ +private fun Event.isGoingRsvp(latestRsvps: Lazy, CalendarRSVPEvent>>): Boolean { + if (this !is CalendarRSVPEvent || status() != RSVPStatusTag.STATUS.ACCEPTED) return false + // An `a` tag at anything but an appointment has no event card to draw next to "Going". + val target = calendarEventAddress() ?: return false + if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return false + return isLatestAnswerIn(latestRsvps.value) +} + +// Built from the cache's RSVP kind index, and only when a pass actually meets an RSVP. +private fun cachedRsvpAnswers(): Map, CalendarRSVPEvent> = latestRsvpAnswers(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND).mapNotNull { it.event as? CalendarRSVPEvent }) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt index ba83376d7a..f81f5e9c22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/HomeTabsSettingsScreen.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.resources.conversations import com.vitorpamplona.amethyst.commons.resources.home_content_type_articles import com.vitorpamplona.amethyst.commons.resources.home_content_type_attestations import com.vitorpamplona.amethyst.commons.resources.home_content_type_birds +import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps import com.vitorpamplona.amethyst.commons.resources.home_content_type_chess import com.vitorpamplona.amethyst.commons.resources.home_content_type_classifieds import com.vitorpamplona.amethyst.commons.resources.home_content_type_comments @@ -183,6 +184,7 @@ private val HOME_FEED_TYPES = HomeFeedTypeUi(HomeFeedType.PODCASTS, Res.string.home_content_type_podcasts, MaterialSymbols.Podcasts), HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, Res.string.home_content_type_fundraisers, MaterialSymbols.Paid), HomeFeedTypeUi(HomeFeedType.RATINGS, Res.string.home_content_type_ratings, MaterialSymbols.Star), + HomeFeedTypeUi(HomeFeedType.CALENDAR_RSVPS, Res.string.home_content_type_calendar_rsvps, MaterialSymbols.CalendarMonth), ) /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt index 204864b038..83716db163 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/HomeFeedType.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent @@ -108,6 +109,7 @@ enum class HomeFeedType( PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)), FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)), RATINGS("ratings", listOf(EntityRatingEvent.KIND)), + CALENDAR_RSVPS("calendar_rsvps", listOf(CalendarRSVPEvent.KIND)), ; companion object { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt index 9842410406..b6eaf25d15 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/CalendarKinds.kt @@ -30,6 +30,11 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent // a second round-trip, but they don't drive the timeline DAL. val CalendarAppointmentKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND) +// What an author publishes as the owner of a calendar, as opposed to answering someone else's. +val CalendarOwnKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarCollectionEvent.KIND) + +val CalendarRsvpKinds = listOf(CalendarRSVPEvent.KIND) + val AllCalendarKinds = listOf( CalendarTimeSlotEvent.KIND, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt index f7dc60e45c..ebf9ae4738 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/calendars/FilterCalendarsByAuthors.kt @@ -42,7 +42,21 @@ fun filterCalendarsByAuthors( ExplainedFilter( purpose = SubPurpose.TOPIC_FEED, authors = authorList, - kinds = AllCalendarKinds, + kinds = CalendarOwnKinds, + limit = 500, + since = since, + ), + ), + // RSVPs get their own limit: they are what the "follows going" lens is built from, and + // sharing one 500-event window with every appointment and calendar these authors ever + // published let a busy organizer starve out everyone else's answers. + RelayBasedFilter( + relay = relay, + filter = + ExplainedFilter( + purpose = SubPurpose.TOPIC_FEED, + authors = authorList, + kinds = CalendarRsvpKinds, limit = 500, since = since, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt index e9d27d4d9f..b4f76d0e51 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/home/nip65Follows/FilterHomePostsByAuthors.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongFormContentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent @@ -79,6 +80,9 @@ val HomePostsNewThreadKinds1 = VideoShortEvent.KIND, AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND, + // "Going to " from people you follow. Low-volume per author, so it rides the main + // leg instead of the 5-item rare-kinds leg below, where it would be starved by the others. + CalendarRSVPEvent.KIND, ) val HomePostsNewThreadKinds2 = diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt new file mode 100644 index 0000000000..adcbe3d589 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip52Calendar + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent + +/** + * One appointment and the people (already narrowed to the ones the viewer cares about) whose + * latest RSVP to it says "going". [attendees] is newest-RSVP first, so the faces a row shows are + * the people who decided most recently. + * + * A data class on purpose: the fold re-runs on every RSVP or appointment that lands, and value + * equality is what lets the StateFlow drop a fold that changed nothing and lets an unchanged row + * skip recomposition. [appointment] compares by identity, which is right — the cache hands out + * one [AddressableNote] per address. + */ +@Immutable +data class AppointmentAttendance( + val appointment: AddressableNote, + val attendees: List, +) + +/** + * [upcoming] is what a "where are my follows going" list draws, soonest first. [unresolved] are + * the appointments someone RSVP'd to that the cache has no event for yet, most recently answered + * first: whether they are upcoming at all is unknown until they load, so they are handed back for + * the caller to fetch instead of being drawn as blank rows. The order matters to a caller that + * can only fetch a bounded number — a fresh RSVP is the one most likely to be for a future event. + */ +@Immutable +data class FollowsGoing( + val upcoming: List, + val unresolved: List, +) + +/** + * Folds a pile of kind-31925 RSVPs into the appointments the accepted authors are going to. + * + * - Only an author's **latest** RSVP to a given appointment counts. RSVPs are addressable with a + * free-form `d` tag, so a person who said "going" and later "can't go" may have two live events + * (two d-tags); the newest one is their answer. + * - [isAttendee] runs on that latest RSVP only: the follow-list / mute check belongs to the caller. + * - [isAppointmentVisible] runs on the appointment's address before it is resolved, so an event + * hosted by someone the viewer muted is neither drawn nor fetched. + * - An appointment counts as upcoming while it has not ended (an ongoing multi-day conference is + * still somewhere your friends are), matching the feed lens's upcoming/past split. + * + * [nowSeconds] is a parameter so the split is deterministic under test. + */ +fun computeFollowsGoing( + rsvps: Collection, + isAttendee: (CalendarRSVPEvent) -> Boolean, + appointmentFor: (Address) -> AddressableNote, + nowSeconds: Long, + isAppointmentVisible: (Address) -> Boolean = { true }, +): FollowsGoing { + val latest = latestRsvpAnswers(rsvps) + + val goingByAppointment = HashMap>() + latest.forEach { (key, rsvp) -> + if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED && isAttendee(rsvp)) { + goingByAppointment.getOrPut(key.first) { mutableListOf() }.add(rsvp) + } + } + + val upcoming = ArrayList>() + val unresolved = ArrayList>() + + goingByAppointment.forEach { (address, going) -> + if (!isAppointmentVisible(address)) return@forEach + + going.sortWith(NEWEST_FIRST) + + val appointment = appointmentFor(address) + if (appointment.event == null) { + unresolved.add(going.first().createdAt to appointment) + return@forEach + } + val start = appointment.calendarStartSeconds() ?: return@forEach + val end = appointment.calendarEndSeconds() ?: start + if (end < nowSeconds) return@forEach + + upcoming.add(start to AppointmentAttendance(appointment, going.map { it.pubKey })) + } + + upcoming.sortWith( + compareBy> { it.first } + .thenByDescending { it.second.attendees.size } + .thenBy { it.second.appointment.idHex }, + ) + unresolved.sortWith( + compareByDescending> { it.first } + .thenBy { it.second.idHex }, + ) + + return FollowsGoing(upcoming.map { it.second }, unresolved.map { it.second }) +} + +/** + * Each author's newest RSVP to each appointment, keyed (appointment, author). RSVPs are + * addressable with a free-form `d` tag, so one person can hold several live answers to the same + * appointment (said "going", then "can't go" under a new d tag); only the newest is their answer. + * RSVPs pointing at anything but an appointment kind are skipped. + */ +fun latestRsvpAnswers(rsvps: Collection): Map, CalendarRSVPEvent> { + val latest = HashMap, CalendarRSVPEvent>() + rsvps.forEach { rsvp -> + val target = rsvp.calendarEventAddress() ?: return@forEach + if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return@forEach + val key = target to rsvp.pubKey + val current = latest[key] + if (current == null || rsvp.isNewerThan(current)) { + latest[key] = rsvp + } + } + return latest +} + +/** Whether this RSVP is its author's current answer in [latest] (see [latestRsvpAnswers]). */ +fun CalendarRSVPEvent.isLatestAnswerIn(latest: Map, CalendarRSVPEvent>): Boolean { + val target = calendarEventAddress() ?: return false + return latest[target to pubKey]?.id == id +} + +// Newest first; the id breaks a same-second tie so the answer never depends on input order. +private val NEWEST_FIRST = + compareByDescending { it.createdAt }.thenBy { it.id } + +private fun CalendarRSVPEvent.isNewerThan(other: CalendarRSVPEvent) = NEWEST_FIRST.compare(this, other) < 0 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt new file mode 100644 index 0000000000..9fc3890833 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip52Calendar + +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarCollectionEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FollowsGoingTest { + private val now = 1_800_000_000L + private val host = "a".repeat(64) + private val alice = "1".repeat(64) + private val bob = "2".repeat(64) + private val carol = "3".repeat(64) + + private val notes = HashMap() + + private fun appointmentFor(address: Address) = notes.getOrPut(address) { AddressableNote(address) } + + private fun timeSlot( + dTag: String, + start: Long, + end: Long? = null, + ): Address { + val address = Address(CalendarTimeSlotEvent.KIND, host, dTag) + val tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("title", dTag)) + add(arrayOf("start", start.toString())) + end?.let { add(arrayOf("end", it.toString())) } + }.toTypedArray() + appointmentFor(address).event = CalendarTimeSlotEvent("id-$dTag", host, 0L, tags, "", "sig") + return address + } + + private var rsvpCounter = 0 + + private fun rsvp( + author: String, + target: Address, + status: String, + createdAt: Long, + dTag: String = "rsvp-${rsvpCounter++}", + ) = CalendarRSVPEvent( + id = "rsvp-${rsvpCounter++}", + pubKey = author, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("d", dTag), + arrayOf("a", target.toValue()), + arrayOf("status", status), + ), + content = "", + sig = "sig", + ) + + private fun fold( + rsvps: List, + isAttendee: (CalendarRSVPEvent) -> Boolean = { true }, + ) = computeFollowsGoing(rsvps, isAttendee, ::appointmentFor, now) + + @Test + fun groupsGoingAuthorsPerAppointment_newestAnswerFirst() { + val conf = timeSlot("conf", start = now + 86_400) + + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10), + rsvp(bob, conf, "accepted", createdAt = 20), + ), + ) + + assertEquals(1, result.upcoming.size) + assertEquals(listOf(bob, alice), result.upcoming.single().attendees) + } + + @Test + fun onlyTheLatestAnswerPerAuthorCounts_evenAcrossDTags() { + val conf = timeSlot("conf", start = now + 86_400) + + // Alice said going, then changed her mind in a second RSVP with a different d tag. + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10, dTag = "first"), + rsvp(alice, conf, "declined", createdAt = 20, dTag = "second"), + rsvp(bob, conf, "tentative", createdAt = 30), + ), + ) + + assertTrue(result.upcoming.isEmpty()) + } + + @Test + fun attendeeFilterRunsOnTheLatestAnswer() { + val conf = timeSlot("conf", start = now + 86_400) + + val result = + fold( + listOf( + rsvp(alice, conf, "accepted", createdAt = 10), + rsvp(carol, conf, "accepted", createdAt = 20), + ), + isAttendee = { it.pubKey != carol }, + ) + + assertEquals(listOf(alice), result.upcoming.single().attendees) + } + + @Test + fun dropsEndedEvents_keepsOngoingOnes_sortsSoonestFirst() { + val past = timeSlot("past", start = now - 7_200, end = now - 3_600) + val ongoing = timeSlot("ongoing", start = now - 3_600, end = now + 3_600) + val later = timeSlot("later", start = now + 10 * 86_400) + val sooner = timeSlot("sooner", start = now + 86_400) + + val result = + fold( + listOf(past, ongoing, later, sooner).map { rsvp(alice, it, "accepted", createdAt = 10) }, + ) + + assertEquals( + listOf(ongoing, sooner, later), + result.upcoming.map { it.appointment.address }, + ) + } + + @Test + fun appointmentsNotInTheCacheAreReportedForFetching() { + val missing = Address(CalendarDateSlotEvent.KIND, host, "not-loaded") + + val result = fold(listOf(rsvp(alice, missing, "accepted", createdAt = 10))) + + assertTrue(result.upcoming.isEmpty()) + assertEquals(listOf(missing), result.unresolved.map { it.address }) + } + + @Test + fun ignoresRsvpsThatPointAtNonAppointments() { + val calendar = Address(CalendarCollectionEvent.KIND, host, "cal") + + val result = fold(listOf(rsvp(alice, calendar, "accepted", createdAt = 10))) + + assertTrue(result.upcoming.isEmpty()) + assertTrue(result.unresolved.isEmpty()) + } + + @Test + fun appointmentsByAHiddenHostAreNeitherShownNorFetched() { + val conf = timeSlot("conf", start = now + 86_400) + val missing = Address(CalendarTimeSlotEvent.KIND, host, "not-loaded") + + val result = + computeFollowsGoing( + rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(alice, missing, "accepted", 11)), + isAttendee = { true }, + appointmentFor = ::appointmentFor, + nowSeconds = now, + isAppointmentVisible = { it.pubKeyHex != host }, + ) + + assertTrue(result.upcoming.isEmpty()) + assertTrue(result.unresolved.isEmpty()) + } + + @Test + fun unresolvedAreOrderedByTheMostRecentRsvp() { + val old = Address(CalendarTimeSlotEvent.KIND, host, "old") + val fresh = Address(CalendarTimeSlotEvent.KIND, host, "fresh") + val middle = Address(CalendarTimeSlotEvent.KIND, host, "middle") + + val result = + fold( + listOf( + rsvp(alice, old, "accepted", createdAt = 10), + rsvp(alice, fresh, "accepted", createdAt = 30), + rsvp(alice, middle, "accepted", createdAt = 15), + // Bob's newer answer makes "middle" the most recent of all. + rsvp(bob, middle, "accepted", createdAt = 40), + ), + ) + + assertEquals(listOf(middle, fresh, old), result.unresolved.map { it.address }) + } + + @Test + fun anUnchangedFoldIsEqualToThePreviousOne() { + val conf = timeSlot("conf", start = now + 86_400) + val rsvps = listOf(rsvp(alice, conf, "accepted", 10), rsvp(bob, conf, "accepted", 20)) + + // The view model's StateFlow relies on this to drop no-op folds. + assertEquals(fold(rsvps), fold(rsvps.reversed())) + } + + @Test + fun anOlderGoingIsNotTheCurrentAnswerOnceTheAuthorDeclines() { + // The Home feed's rule: the "going" under the old d tag is still live, but it is not + // Alice's answer any more. + val conf = timeSlot("conf", start = now + 86_400) + val going = rsvp(alice, conf, "accepted", createdAt = 10) + val declined = rsvp(alice, conf, "declined", createdAt = 20) + + val latest = latestRsvpAnswers(listOf(going, declined)) + + assertTrue(declined.isLatestAnswerIn(latest)) + assertFalse(going.isLatestAnswerIn(latest)) + } + + @Test + fun anAnswerStaysCurrentWhenOnlyOtherPeopleOrOtherEventsChange() { + val conf = timeSlot("conf", start = now + 86_400) + val party = timeSlot("party", start = now + 2 * 86_400) + val aliceConf = rsvp(alice, conf, "accepted", createdAt = 10) + val aliceParty = rsvp(alice, party, "declined", createdAt = 30) + val bobConf = rsvp(bob, conf, "declined", createdAt = 40) + + val latest = latestRsvpAnswers(listOf(aliceConf, aliceParty, bobConf)) + + assertTrue(aliceConf.isLatestAnswerIn(latest)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 8bd04c1ebc..e50c6fd0f3 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -340,6 +340,7 @@ Location Going Maybe + Loading event… Can't go %1$d event @@ -3461,6 +3462,13 @@ Calendar reminders Day Feed + Friends going + No plans yet + When people in this list RSVP “Going” to an upcoming event, it shows up here. + + %1$d going + %1$d going + Month Week Accept @@ -3908,6 +3916,7 @@ Podcasts Polls Ratings and reviews + Event RSVPs Reposts Shorts Text notes diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt index ac135fc16d..4b04ae678f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip52Calendar/ui/CalendarsViewMode.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.nip52Calendar.ui import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_view_day import com.vitorpamplona.amethyst.commons.resources.calendar_view_feed +import com.vitorpamplona.amethyst.commons.resources.calendar_view_follows_going import com.vitorpamplona.amethyst.commons.resources.calendar_view_month import com.vitorpamplona.amethyst.commons.resources.calendar_view_week import org.jetbrains.compose.resources.StringResource @@ -31,11 +32,15 @@ import org.jetbrains.compose.resources.StringResource * Lenses on the same appointment timeline. Calendar *collections* (kind 31924) live on their * own screen ([CalendarCollectionsScreen]) since they're a sibling feed, not a different view * of the appointment data. + * + * [FOLLOWS_GOING] is the one lens not drawn from the appointment timeline: it is built from the + * RSVPs of the people in the selected list, showing the upcoming events they said they'd attend. */ enum class CalendarsViewMode( val labelRes: StringResource, ) { FEED(Res.string.calendar_view_feed), + FOLLOWS_GOING(Res.string.calendar_view_follows_going), MONTH(Res.string.calendar_view_month), WEEK(Res.string.calendar_view_week), DAY(Res.string.calendar_view_day), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt index 53068020ce..f0f066ce28 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CalendarRsvpCard.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.commons.ui.note +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -34,84 +37,135 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going +import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_loading_event import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going -import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import org.jetbrains.compose.resources.stringResource -/** Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target. */ +/** + * A NIP-52 calendar RSVP (kind 31925) as one card: the author's note as post text, then a single + * quote-style frame whose tinted strip says how they answered and whose body is the appointment + * they answered. + * + * The status lives *inside* the appointment's frame on purpose. It is a property of "Alice → this + * event", so a header line floating above a separately framed event read as two unrelated posts; + * and the frame is the feed's plain quote border, not a raised card, so the RSVP never nests a + * box inside a box. + * + * [appointment] draws the event once the front end has it (no header of its own — the post's + * author is who answered, and a second avatar row made it read like a quote of a quote). Until + * then the strip stands over a muted "loading" line. [statusDetail] is the strip's trailing text, + * e.g. "in 3 days". [onClick] makes the whole frame open the event. + */ @Composable -fun CalendarRsvpCard(event: CalendarRSVPEvent) { +fun CalendarRsvpCard( + event: CalendarRSVPEvent, + statusDetail: String? = null, + onClick: (() -> Unit)? = null, + appointment: (@Composable ColumnScope.() -> Unit)? = null, +) { val status = remember(event) { event.status() } - val targetAddress = remember(event) { event.calendarEventAddress() } - val freebusy = remember(event) { event.freebusy() } - val statusLabel = - when (status) { - RSVPStatusTag.STATUS.ACCEPTED -> stringResource(Res.string.calendar_rsvp_going) - RSVPStatusTag.STATUS.TENTATIVE -> stringResource(Res.string.calendar_rsvp_maybe) - RSVPStatusTag.STATUS.DECLINED -> stringResource(Res.string.calendar_rsvp_not_going) - null -> "—" - } - - val statusColor = - when (status) { - RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary - RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary - RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error - null -> Color.Gray - } - - Column(MaterialTheme.colorScheme.replyModifier) { - Row( - modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.CalendarMonth, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = statusColor, - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = statusLabel, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = statusColor, - ) + val frame = + if (onClick != null) { + MaterialTheme.colorScheme.replyModifier.clickable(onClick = onClick) + } else { + MaterialTheme.colorScheme.replyModifier } + Column { if (event.content.isNotBlank()) { - Spacer(modifier = StdVertSpacer) Text( text = event.content, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 10.dp, end = 10.dp), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 4.dp), ) } - targetAddress?.let { addr -> - Spacer(modifier = StdVertSpacer) - Text( - text = "→ ${addr.toValue()}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp), - ) - } + Column(frame) { + RsvpStatusStrip(status, statusDetail) - if (freebusy != null) { - Spacer(modifier = StdVertSpacer) + if (appointment != null) { + appointment() + } else { + Text( + text = stringResource(Res.string.calendar_rsvp_loading_event), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } } } } + +@Composable +private fun RsvpStatusStrip( + status: RSVPStatusTag.STATUS?, + detail: String?, +) { + val (label, color, symbol) = rsvpStatusLook(status) + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(color.copy(alpha = 0.12f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = color, + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = color, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private data class RsvpStatusLook( + val label: String, + val color: Color, + val symbol: MaterialSymbol, +) + +@Composable +private fun rsvpStatusLook(status: RSVPStatusTag.STATUS?): RsvpStatusLook = + when (status) { + RSVPStatusTag.STATUS.ACCEPTED -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_going), MaterialTheme.colorScheme.primary, MaterialSymbols.CheckCircle) + RSVPStatusTag.STATUS.TENTATIVE -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_maybe), MaterialTheme.colorScheme.tertiary, MaterialSymbols.Schedule) + RSVPStatusTag.STATUS.DECLINED -> + RsvpStatusLook(stringResource(Res.string.calendar_rsvp_not_going), MaterialTheme.colorScheme.error, MaterialSymbols.Cancel) + null -> + RsvpStatusLook("—", Color.Gray, MaterialSymbols.CalendarMonth) + }