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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk
This commit is contained in:
Claude
2026-09-28 18:45:56 +00:00
parent 1d3fae874d
commit f409df6643
6 changed files with 179 additions and 40 deletions
@@ -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 = {
@@ -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)
}
@@ -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<AttendeeInputs?>(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<AddressableNote> = ConcurrentHashMap.newKeySet()
private var rsvpPinner: Job? = null
/** Idempotent — the screen calls it on every composition. */
fun bindAttendeeFilter(
lists: StateFlow<IFeedTopNavFilter>,
@@ -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<CalendarRSVPEvent>(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<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND))),
LocalCache
.observeNewEvents<Event>(Filter(kinds = CalendarAppointmentKinds))
.observeNewEvents<Event>(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<NormalizedRelayUrl> =
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
}
}
@@ -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
}
@@ -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<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.
* 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<AppointmentAttendance>,
val unresolved: List<AddressableNote>,
)
@@ -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<Pair<Address, HexKey>, 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<Pair<Long, AppointmentAttendance>>()
val unresolved = ArrayList<AddressableNote>()
val unresolved = ArrayList<Pair<Long, AddressableNote>>()
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<Pair<Long, AddressableNote>> { 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<CalendarRSVPEvent> { it.createdAt }.thenBy { it.id }
private fun CalendarRSVPEvent.isNewerThan(other: CalendarRSVPEvent) = NEWEST_FIRST.compare(this, other) < 0
@@ -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()))
}
}