From eddf0f74ae29c5d93788507bb6f9808056d29306 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Sep 2026 16:37:01 -0400 Subject: [PATCH] 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)) + } }