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 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-28 17:16:59 -04:00
co-authored by Claude Opus 5.5
parent d3e38285b8
commit eddf0f74ae
3 changed files with 89 additions and 17 deletions
@@ -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<Note> {
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<Note>): Set<Note> = 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<Note>,
newItems: Set<Note>,
): List<Note> {
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<Note>): Set<Note> {
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<Int>,
latestRsvps: Lazy<Map<Pair<Address, HexKey>, 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<Map<Pair<Address, HexKey>, 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<Pair<Address, HexKey>, CalendarRSVPEvent> = latestRsvpAnswers(LocalCache.addressables.filterIntoSet(CalendarRSVPEvent.KIND).mapNotNull { it.event as? CalendarRSVPEvent })
@@ -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<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.isNewerThan(current)) {
latest[key] = rsvp
}
}
val latest = latestRsvpAnswers(rsvps)
val goingByAppointment = HashMap<Address, MutableList<CalendarRSVPEvent>>()
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<CalendarRSVPEvent>): Map<Pair<Address, HexKey>, CalendarRSVPEvent> {
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.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<Pair<Address, HexKey>, 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<CalendarRSVPEvent> { it.createdAt }.thenBy { it.id }
@@ -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))
}
}