mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+4
-4
@@ -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 = {
|
||||
|
||||
+9
-1
@@ -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)
|
||||
}
|
||||
|
||||
+77
-19
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -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
|
||||
}
|
||||
|
||||
+34
-12
@@ -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
|
||||
|
||||
+47
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user