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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk
This commit is contained in:
Claude
2026-09-28 17:07:54 +00:00
parent b8f59c6e72
commit 6e3c283c87
16 changed files with 648 additions and 40 deletions
@@ -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,
)
}
}
}
@@ -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()
}
}
@@ -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<AddressableNote>,
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,
)
}
}
@@ -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)
@@ -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<IFeedTopNavFilter>,
val hidden: StateFlow<LiveHiddenUsers>,
)
private val attendeeInputs = MutableStateFlow<AttendeeInputs?>(null)
/** Idempotent — the screen calls it on every composition. */
fun bindAttendeeFilter(
lists: StateFlow<IFeedTopNavFilter>,
hidden: StateFlow<LiveHiddenUsers>,
) {
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<FollowsGoing?> =
attendeeInputs
.filterNotNull()
.flatMapLatest { (lists, hidden) ->
combine(
LocalCache.observeEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND))),
LocalCache
.observeNewEvents<Event>(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
// ------------------------------------------------------------------------------------------
@@ -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
@@ -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),
)
/**
@@ -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 {
@@ -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,
@@ -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,
),
@@ -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 <event>" 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 =
@@ -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<HexKey>,
)
/**
* [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<AppointmentAttendance>,
val unresolved: List<AddressableNote>,
)
/**
* 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<CalendarRSVPEvent>,
isAttendee: (CalendarRSVPEvent) -> Boolean,
appointmentFor: (Address) -> AddressableNote,
nowSeconds: Long,
): FollowsGoing {
// (appointment, author) -> that author's newest answer to it
val latest = HashMap<Pair<Address, HexKey>, 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<Address, MutableList<CalendarRSVPEvent>>()
latest.forEach { (key, rsvp) ->
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED && isAttendee(rsvp)) {
goingByAppointment.getOrPut(key.first) { mutableListOf() }.add(rsvp)
}
}
val upcoming = ArrayList<Pair<Long, AppointmentAttendance>>()
val unresolved = ArrayList<AddressableNote>()
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<Pair<Long, AppointmentAttendance>> { it.first }
.thenByDescending { it.second.attendees.size }
.thenBy { it.second.appointment.idHex },
)
return FollowsGoing(upcoming.map { it.second }, unresolved)
}
@@ -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<Address, AddressableNote>()
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<CalendarRSVPEvent>,
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())
}
}
@@ -3458,6 +3458,13 @@
<string name="calendar_reminder_settings_title">Calendar reminders</string>
<string name="calendar_view_day">Day</string>
<string name="calendar_view_feed">Feed</string>
<string name="calendar_view_follows_going">Friends going</string>
<string name="calendar_follows_going_empty_title">No plans yet</string>
<string name="calendar_follows_going_empty_subtitle">When people in this list RSVP “Going” to an upcoming event, it shows up here.</string>
<plurals name="calendar_follows_going_count">
<item quantity="one">%1$d going</item>
<item quantity="other">%1$d going</item>
</plurals>
<string name="calendar_view_month">Month</string>
<string name="calendar_view_week">Week</string>
<string name="call_accept">Accept</string>
@@ -3905,6 +3912,7 @@
<string name="home_content_type_podcasts">Podcasts</string>
<string name="home_content_type_polls">Polls</string>
<string name="home_content_type_ratings">Ratings and reviews</string>
<string name="home_content_type_calendar_rsvps">Event RSVPs</string>
<string name="home_content_type_reposts">Reposts</string>
<string name="home_content_type_shorts">Shorts</string>
<string name="home_content_type_text_notes">Text notes</string>
@@ -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),
@@ -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) {