From 6e3c283c874015fc8a7b50e16e91f2b120d73bba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:07:54 +0000 Subject: [PATCH 1/7] feat(calendar): show follows' RSVPs on Home and add a Friends going lens - Home: accepted NIP-52 RSVPs (kind 31925) from the selected list now load and render in New Threads, with a Settings > Home toggle (Event RSVPs). The RSVP card embeds the appointment it answers instead of its raw address. - Calendars: new "Friends going" lens lists the upcoming events the people in the selected list RSVP'd going to, soonest first, with their faces. Appointments missing from the cache are fetched by address. - Calendar author subscription asks for RSVPs on their own limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk --- .../ui/note/types/CalendarRsvpRender.kt | 50 ++--- .../calendars/CalendarEventListCard.kt | 4 + .../calendars/CalendarFollowsGoingView.kt | 154 ++++++++++++++++ .../loggedIn/calendars/CalendarsScreen.kt | 23 ++- .../loggedIn/calendars/CalendarsViewModel.kt | 78 ++++++++ .../home/dal/HomeNewThreadFeedFilter.kt | 16 +- .../settings/HomeTabsSettingsScreen.kt | 2 + .../amethyst/commons/model/HomeFeedType.kt | 2 + .../relayClient/calendars/CalendarKinds.kt | 5 + .../calendars/FilterCalendarsByAuthors.kt | 16 +- .../nip65Follows/FilterHomePostsByAuthors.kt | 4 + .../model/nip52Calendar/FollowsGoing.kt | 116 ++++++++++++ .../model/nip52Calendar/FollowsGoingTest.kt | 174 ++++++++++++++++++ .../composeResources/values/strings.xml | 8 + .../nip52Calendar/ui/CalendarsViewMode.kt | 5 + .../commons/ui/note/CalendarRsvpCard.kt | 31 +++- 16 files changed, 648 insertions(+), 40 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarFollowsGoingView.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt 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..b682c63980 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 @@ -27,29 +27,15 @@ 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.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.CalendarEventListCard 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 inside it + * — "going" is only interesting next to *what* — falling back to the bare address until it loads. * * [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 @@ -67,15 +53,35 @@ 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) { + if (appointment == null) { + CalendarRsvpCard(event) + } else { EventFinderFilterAssemblerSubscription(appointment, accountViewModel) + + WatchNoteEvent( + baseNote = appointment, + onNoteEventFound = { + CalendarRsvpCard(event) { + CalendarEventListCard(appointment, accountViewModel, nav) + } + }, + onBlank = { CalendarRsvpCard(event) }, + accountViewModel = accountViewModel, + ) } } } 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..00a345c829 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 @@ -75,6 +75,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 @@ -189,6 +191,8 @@ fun CalendarEventListCard( } } } + + footer?.invoke() } } 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..58b66fc677 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 @@ -69,6 +69,7 @@ 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() @@ -80,13 +81,20 @@ 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 = { @@ -109,6 +117,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/CalendarsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarsViewModel.kt index fe401d2b98..2e57b043f4 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 @@ -30,6 +30,16 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.feeds.FilterByListParams +import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing +import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.onStart import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache @@ -260,6 +270,74 @@ 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) + + /** 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 + } + + /** + * 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 two cache observers: every kind-31925 (the RSVPs themselves) and every new + * appointment, because an RSVP usually lands before the appointment it answers — the author + * of the appointment is often someone the viewer does not follow — and the row can only be + * placed once the appointment's date is known. Both kinds are low-volume. + * + * `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.observeEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))), + LocalCache + .observeNewEvents(Filter(kinds = CalendarAppointmentKinds)) + .map { } + .onStart { emit(Unit) }, + lists, + hidden, + ) { rsvps, _, list, hiddenUsers -> + val params = FilterByListParams.create(list, hiddenUsers) + computeFollowsGoing( + rsvps = rsvps, + isAttendee = { rsvp -> + // Relays only matter to a relay-scoped list; they live on the canonical note. + val relays = LocalCache.getAddressableNoteIfExists(rsvp.address())?.relays ?: emptyList() + params.match(rsvp, relays) + }, + appointmentFor = LocalCache::getOrCreateAddressableNote, + nowSeconds = TimeUtils.now(), + ) + } + }.flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + + val followsGoingListState = LazyListState() + // ------------------------------------------------------------------------------------------ // Paging // ------------------------------------------------------------------------------------------ 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..67f20d7320 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 @@ -45,12 +45,15 @@ 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.Event 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.tags.RSVPStatusTag +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 +90,7 @@ class HomeNewThreadFeedFilter( AddressableNormalVideoEvent.KIND, AddressableShortVideoEvent.KIND, EntityRatingEvent.KIND, + CalendarRSVPEvent.KIND, ) } @@ -174,7 +178,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() ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() @@ -190,3 +195,12 @@ 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. + */ +private fun Event.isGoingRsvp() = + this is CalendarRSVPEvent && + status() == RSVPStatusTag.STATUS.ACCEPTED && + calendarEventAddress() != null 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..d84b6fb466 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 @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.resources.home_content_type_nips import com.vitorpamplona.amethyst.commons.resources.home_content_type_pictures import com.vitorpamplona.amethyst.commons.resources.home_content_type_podcasts import com.vitorpamplona.amethyst.commons.resources.home_content_type_polls +import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps import com.vitorpamplona.amethyst.commons.resources.home_content_type_ratings import com.vitorpamplona.amethyst.commons.resources.home_content_type_reposts import com.vitorpamplona.amethyst.commons.resources.home_content_type_shorts @@ -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..0690c563f4 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoing.kt @@ -0,0 +1,116 @@ +/* + * 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. + */ +@Immutable +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: 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. + */ +@Immutable +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) or an old version still in the cache; the newest one is their answer. + * - [isAttendee] runs on that latest RSVP only: the follow-list / mute check belongs to the caller. + * - 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, +): FollowsGoing { + // (appointment, author) -> that author's newest answer to it + 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.createdAt > current.createdAt) { + latest[key] = rsvp + } + } + + 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) -> + val appointment = appointmentFor(address) + if (appointment.event == null) { + unresolved.add(appointment) + return@forEach + } + val start = appointment.calendarStartSeconds() ?: return@forEach + val end = appointment.calendarEndSeconds() ?: start + if (end < nowSeconds) return@forEach + + val attendees = going.sortedByDescending { it.createdAt }.map { it.pubKey } + upcoming.add(start to AppointmentAttendance(appointment, attendees)) + } + + upcoming.sortWith( + compareBy> { it.first } + .thenByDescending { it.second.attendees.size } + .thenBy { it.second.appointment.idHex }, + ) + + return FollowsGoing(upcoming.map { it.second }, unresolved) +} 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..09afeb3f1e --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip52Calendar/FollowsGoingTest.kt @@ -0,0 +1,174 @@ +/* + * 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.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()) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 868ac7ec31..2392d23c21 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -3458,6 +3458,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 @@ -3905,6 +3912,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..cb917ceb22 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 @@ -47,9 +47,17 @@ 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. */ +/** + * Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target. + * + * [target] draws the appointment being answered once the front end has it; until then (or when + * it is null) the card falls back to printing the appointment's address. + */ @Composable -fun CalendarRsvpCard(event: CalendarRSVPEvent) { +fun CalendarRsvpCard( + event: CalendarRSVPEvent, + target: (@Composable () -> Unit)? = null, +) { val status = remember(event) { event.status() } val targetAddress = remember(event) { event.calendarEventAddress() } val freebusy = remember(event) { event.freebusy() } @@ -100,14 +108,19 @@ fun CalendarRsvpCard(event: CalendarRSVPEvent) { ) } - targetAddress?.let { addr -> + if (target != null) { 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), - ) + target() + } else { + 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), + ) + } } if (freebusy != null) { From 1d3fae874d089d8af625d1a366274dd6e0df28d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:09:49 +0000 Subject: [PATCH 2/7] style: apply spotless Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk --- .../loggedIn/calendars/CalendarsViewModel.kt | 18 +++++++++--------- .../settings/HomeTabsSettingsScreen.kt | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) 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 2e57b043f4..bc7ab13f71 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 @@ -30,27 +30,26 @@ import androidx.compose.runtime.setValue 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.LiveHiddenUsers -import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing -import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing -import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter -import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.flow.onStart -import com.vitorpamplona.amethyst.commons.feeds.FeedState import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +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.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.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.flow.MutableStateFlow @@ -63,6 +62,7 @@ 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 java.time.LocalDate import java.time.YearMonth 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 d84b6fb466..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 @@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.resources.home_content_type_nips import com.vitorpamplona.amethyst.commons.resources.home_content_type_pictures import com.vitorpamplona.amethyst.commons.resources.home_content_type_podcasts import com.vitorpamplona.amethyst.commons.resources.home_content_type_polls -import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps import com.vitorpamplona.amethyst.commons.resources.home_content_type_ratings import com.vitorpamplona.amethyst.commons.resources.home_content_type_reposts import com.vitorpamplona.amethyst.commons.resources.home_content_type_shorts From f409df66434d282ff1e157f143f45a7b8112e338 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 18:45:56 +0000 Subject: [PATCH 3/7] fix(calendar): pin RSVPs and fold Friends going off the kind index Audit of the follows-going lens and the Home RSVP card: - RSVPs live in LocalCache.addressables, which holds values by weak reference, and nothing else references someone else's RSVP. Those pulled in while the user was on another lens could be collected before "Friends going" opened, and the EOSE cursor had already moved past them. The screen's view model now pins every RSVP it sees while it lives. - The fold no longer uses observeEvents, whose seed walks every regular note in the cache and which copies its list and id set on each insert. One conflated observer on both kinds wakes a fold that scans the RSVP kind index; conflate() collapses bursts into one recompute. - FollowsGoing / AppointmentAttendance are data classes, so a fold that changed nothing is dropped by the StateFlow and rows skip recomposition. - Appointments hosted by muted/blocked users are neither shown nor fetched. - Unresolved appointments are ordered by most recent RSVP, so the bounded fetch goes to the ones most likely to be upcoming. Same-second RSVPs tie-break on id. - The Home RSVP card no longer keeps a standing event-finder subscription on a loaded appointment; WatchNoteEvent covers the missing case. - Home only accepts RSVPs that point at an appointment kind. - Re-tapping the Calendars tab scrolls the Friends lens to the top. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk --- .../ui/note/types/CalendarRsvpRender.kt | 8 +- .../loggedIn/calendars/CalendarsScreen.kt | 10 +- .../loggedIn/calendars/CalendarsViewModel.kt | 96 +++++++++++++++---- .../home/dal/HomeNewThreadFeedFilter.kt | 12 ++- .../model/nip52Calendar/FollowsGoing.kt | 46 ++++++--- .../model/nip52Calendar/FollowsGoingTest.kt | 47 +++++++++ 6 files changed, 179 insertions(+), 40 deletions(-) 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 b682c63980..fe7acc5a57 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 @@ -25,7 +25,6 @@ import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.Note 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.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -39,7 +38,7 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent * * [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. * @@ -70,8 +69,9 @@ fun RenderCalendarRSVPEvent( if (appointment == null) { CalendarRsvpCard(event) } else { - EventFinderFilterAssemblerSubscription(appointment, accountViewModel) - + // 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 = { 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 58b66fc677..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( @@ -72,6 +74,7 @@ fun CalendarsScreen( model.bindAttendeeFilter(accountViewModel.account.liveCalendarsFollowLists, accountViewModel.account.hiddenUsers.flow) val filterDTag by model.filterDTag.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() DisappearingScaffold( isInvertedLayout = false, @@ -100,7 +103,12 @@ fun CalendarsScreen( 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) } 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 bc7ab13f71..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 @@ -32,31 +32,37 @@ 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 @@ -64,8 +70,10 @@ 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 @@ -285,6 +293,21 @@ class CalendarsViewModel : ViewModel() { 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, @@ -292,6 +315,16 @@ class CalendarsViewModel : ViewModel() { ) { 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) } + } + } } /** @@ -299,10 +332,18 @@ class CalendarsViewModel : ViewModel() { * 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 two cache observers: every kind-31925 (the RSVPs themselves) and every new - * appointment, because an RSVP usually lands before the appointment it answers — the author - * of the appointment is often someone the viewer does not follow — and the row can only be - * placed once the appointment's date is known. Both kinds are low-volume. + * 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. @@ -313,29 +354,44 @@ class CalendarsViewModel : ViewModel() { .filterNotNull() .flatMapLatest { (lists, hidden) -> combine( - LocalCache.observeEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))), LocalCache - .observeNewEvents(Filter(kinds = CalendarAppointmentKinds)) + .observeNewEvents(Filter(kinds = FOLLOWS_GOING_KINDS)) + .conflate() .map { } .onStart { emit(Unit) }, lists, hidden, - ) { rsvps, _, list, hiddenUsers -> - val params = FilterByListParams.create(list, hiddenUsers) - computeFollowsGoing( - rsvps = rsvps, - isAttendee = { rsvp -> - // Relays only matter to a relay-scoped list; they live on the canonical note. - val relays = LocalCache.getAddressableNoteIfExists(rsvp.address())?.relays ?: emptyList() - params.match(rsvp, relays) - }, - appointmentFor = LocalCache::getOrCreateAddressableNote, - nowSeconds = TimeUtils.now(), - ) - } + ) { _, 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() // ------------------------------------------------------------------------------------------ @@ -382,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 67f20d7320..a72de3b801 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 @@ -52,7 +52,9 @@ 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 @@ -200,7 +202,9 @@ class HomeNewThreadFeedFilter( * 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. */ -private fun Event.isGoingRsvp() = - this is CalendarRSVPEvent && - status() == RSVPStatusTag.STATUS.ACCEPTED && - calendarEventAddress() != null +private fun Event.isGoingRsvp(): 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 + return target.kind == CalendarTimeSlotEvent.KIND || target.kind == CalendarDateSlotEvent.KIND +} 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 index 0690c563f4..806a501401 100644 --- 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 @@ -33,21 +33,27 @@ 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 -class AppointmentAttendance( +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: 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 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 -class FollowsGoing( +data class FollowsGoing( val upcoming: List, val unresolved: List, ) @@ -57,8 +63,10 @@ class FollowsGoing( * * - 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) or an old version still in the cache; the newest one is their answer. + * (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. * @@ -69,6 +77,7 @@ fun computeFollowsGoing( isAttendee: (CalendarRSVPEvent) -> Boolean, appointmentFor: (Address) -> AddressableNote, nowSeconds: Long, + isAppointmentVisible: (Address) -> Boolean = { true }, ): FollowsGoing { // (appointment, author) -> that author's newest answer to it val latest = HashMap, CalendarRSVPEvent>() @@ -77,7 +86,7 @@ fun computeFollowsGoing( 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.createdAt > current.createdAt) { + if (current == null || rsvp.isNewerThan(current)) { latest[key] = rsvp } } @@ -90,20 +99,23 @@ fun computeFollowsGoing( } val upcoming = ArrayList>() - val unresolved = 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(appointment) + 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 - val attendees = going.sortedByDescending { it.createdAt }.map { it.pubKey } - upcoming.add(start to AppointmentAttendance(appointment, attendees)) + upcoming.add(start to AppointmentAttendance(appointment, going.map { it.pubKey })) } upcoming.sortWith( @@ -111,6 +123,16 @@ fun computeFollowsGoing( .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) + return FollowsGoing(upcoming.map { it.second }, unresolved.map { it.second }) } + +// 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 index 09afeb3f1e..df460a5073 100644 --- 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 @@ -171,4 +171,51 @@ class FollowsGoingTest { 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())) + } } From c082b006f8bca4dbb282f66417422cb9d6f48cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:14:00 +0000 Subject: [PATCH 4/7] feat(calendar): merge the RSVP card and its event into one frame The Home feed's Going card stacked a bordered box, a status title, the comment, and a raised, shadowed event card carrying the host's own avatar header: two frames and two authors for one post. Now the note's text is the RSVP comment, followed by a single quote-style frame: a tinted status strip (Going / Maybe / Can't go, with "in 3 days" on the right), the cover image full width, and the same date badge and text lines the calendar list uses. No inner card, no second header; the whole frame opens the event. While the event loads the strip sits over a muted loading line instead of the raw a-tag address. The list card's badge and text lines are extracted so both surfaces draw an event the same way. Icons reuse existing codepoints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk --- .../ui/note/types/CalendarRsvpRender.kt | 93 ++++++++++- .../calendars/CalendarEventListCard.kt | 107 ++++++------ .../composeResources/values/strings.xml | 1 + .../commons/ui/note/CalendarRsvpCard.kt | 153 +++++++++++------- 4 files changed, 244 insertions(+), 110 deletions(-) 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 fe7acc5a57..f47b1ce812 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,21 +20,43 @@ */ 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.unit.dp +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard +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.CalendarEventListCard +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.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] with that appointment inside it - * — "going" is only interesting next to *what* — falling back to the bare address until it loads. + * 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 @@ -74,14 +96,69 @@ fun RenderCalendarRSVPEvent( // keep asking relays for the appointment's reactions and zaps on every RSVP in the feed. WatchNoteEvent( baseNote = appointment, - onNoteEventFound = { - CalendarRsvpCard(event) { - CalendarEventListCard(appointment, accountViewModel, nav) - } - }, + 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) 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 00a345c829..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 @@ -120,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()) { @@ -196,8 +153,66 @@ fun CalendarEventListCard( } } +/** + * 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 -private fun CalendarDateBadge(startSeconds: Long?) { +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 +internal fun CalendarDateBadge(startSeconds: Long?) { if (startSeconds == null) { Box( modifier = Modifier.size(width = 52.dp, height = 60.dp), diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 2392d23c21..ceae1383d3 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 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 cb917ceb22..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,97 +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. * - * [target] draws the appointment being answered once the front end has it; until then (or when - * it is null) the card falls back to printing the appointment's address. + * 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, - target: (@Composable () -> Unit)? = null, + 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), ) } - if (target != null) { - Spacer(modifier = StdVertSpacer) - target() - } else { - targetAddress?.let { addr -> - Spacer(modifier = StdVertSpacer) + Column(frame) { + RsvpStatusStrip(status, statusDetail) + + if (appointment != null) { + appointment() + } else { Text( - text = "→ ${addr.toValue()}", - style = MaterialTheme.typography.labelSmall, + text = stringResource(Res.string.calendar_rsvp_loading_event), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp), + modifier = Modifier.padding(12.dp), ) } } + } +} - if (freebusy != null) { - Spacer(modifier = StdVertSpacer) +@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) + } From 318c172a4ba2e3be563c6d38396d37ffe7e55241 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 20:23:39 +0000 Subject: [PATCH 5/7] feat(calendar): add Compose previews for the RSVP card Going with a cover image, Maybe on an all-day event without one, and the loading state before the appointment arrives. The first two seed LocalCache the same way the appointment previews next to them do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk --- .../ui/note/types/CalendarRsvpRender.kt | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) 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 f47b1ce812..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 @@ -35,12 +35,16 @@ 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.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 @@ -50,6 +54,12 @@ 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 /** @@ -162,3 +172,126 @@ private fun RsvpWithAppointment( } 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) + } +} From d3e38285b8f441864d25f74200aed04af1d83e92 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 16:36:59 -0400 Subject: [PATCH 6/7] fix(calendar): the calendar filter scrolls with the lenses instead of hiding one "All calendars" was pinned beside the scrolling lens chips and took its width out of them. Once "Friends going" joined the lenses, the row outgrew an 800px tablet and "Day" sat half-hidden under the pinned chip, with nothing to say the row scrolls. The filter now sits at the end of the same scrolling row, so every lens shows whole and the chip cut by the screen edge is the scroll cue. Checked on the tablet. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/calendars/CalendarsTopBar.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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() } } From eddf0f74ae29c5d93788507bb6f9808056d29306 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 16:37:01 -0400 Subject: [PATCH 7/7] fix(calendar): Home stops showing "Going" once the author changes their answer RSVPs are addressable with a free-form d tag, so someone who said "going" and later "can't go" under a new d tag keeps both live. Friends going already counted only each author's newest answer; Home accepted any "accepted" RSVP, so the old "Going" card stayed. The newest-answer rule is now one shared helper (latestRsvpAnswers / isLatestAnswerIn): Home accepts an RSVP only when it is its author's current answer, the map built from the RSVP kind index only when a pass meets an RSVP. Because the additive merge only adds, a batch carrying RSVPs also drops cards from the list that are no longer current. Tests cover the going-then-declined case and answers to other events or by other people. Co-Authored-By: Claude Opus 5.5 --- .../home/dal/HomeNewThreadFeedFilter.kt | 40 ++++++++++++++++--- .../model/nip52Calendar/FollowsGoing.kt | 38 +++++++++++++----- .../model/nip52Calendar/FollowsGoingTest.kt | 28 +++++++++++++ 3 files changed, 89 insertions(+), 17 deletions(-) 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 a72de3b801..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,7 +47,9 @@ 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 @@ -111,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) @@ -130,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) } } @@ -143,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 @@ -181,7 +203,7 @@ class HomeNewThreadFeedFilter( noteEvent is AttestorProficiencyEvent || // A rating with nothing to point at cannot be rendered. (noteEvent is EntityRatingEvent && noteEvent.hasTarget()) || - noteEvent.isGoingRsvp() + noteEvent.isGoingRsvp(latestRsvps) ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() @@ -201,10 +223,16 @@ class HomeNewThreadFeedFilter( /** * 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(): Boolean { +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 - return target.kind == CalendarTimeSlotEvent.KIND || target.kind == CalendarDateSlotEvent.KIND + 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/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 index 806a501401..adcbe3d589 100644 --- 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 @@ -79,17 +79,7 @@ fun computeFollowsGoing( nowSeconds: Long, isAppointmentVisible: (Address) -> Boolean = { true }, ): FollowsGoing { - // (appointment, author) -> that author's newest answer to it - 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 - } - } + val latest = latestRsvpAnswers(rsvps) val goingByAppointment = HashMap>() latest.forEach { (key, rsvp) -> @@ -131,6 +121,32 @@ fun computeFollowsGoing( 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 } 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 index df460a5073..9fc3890833 100644 --- 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 @@ -28,6 +28,7 @@ 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 { @@ -218,4 +219,31 @@ class FollowsGoingTest { // 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)) + } }