mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
co-authored by
Claude Opus 5.5
parent
d3e38285b8
commit
eddf0f74ae
+34
-6
@@ -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 })
|
||||
|
||||
+27
-11
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user