mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #4258 from vitorpamplona/claude/zealous-meitner-399jm4
Calendar: show appointments inside RSVP cards with attendee faces
This commit is contained in:
+241
-25
@@ -20,40 +20,57 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
|
||||
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.commons.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarAppointmentLines
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarDateBadge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detailRouteFor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatCalendarRange
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.rememberRelativeTimeLabel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
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
|
||||
|
||||
/**
|
||||
* Entry for a NIP-52 calendar RSVP: decodes the [Note], makes sure the appointment it answers is
|
||||
* in the cache, and renders the shared commons [CalendarRsvpCard].
|
||||
*/
|
||||
@Composable
|
||||
fun RenderCalendarRSVPEvent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event as? CalendarRSVPEvent ?: return
|
||||
|
||||
LoadAppointmentBehind(event, accountViewModel)
|
||||
|
||||
CalendarRsvpCard(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the appointment this RSVP answers, from the `a` tag that is the only thing tying the
|
||||
* two together.
|
||||
* in the cache, and renders the shared commons [CalendarRsvpCard] with that appointment merged
|
||||
* into its frame — "going" is only interesting next to *what*. Until the appointment loads the
|
||||
* card shows its status strip over a "loading" line.
|
||||
*
|
||||
* [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.
|
||||
*
|
||||
@@ -67,15 +84,214 @@ fun RenderCalendarRSVPEvent(
|
||||
* scrolls away or the app backgrounds.
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadAppointmentBehind(
|
||||
event: CalendarRSVPEvent,
|
||||
fun RenderCalendarRSVPEvent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val address = remember(event) { event.calendarEventAddress() } ?: return
|
||||
val event = note.event as? CalendarRSVPEvent ?: return
|
||||
val address = remember(event) { event.calendarEventAddress() }
|
||||
|
||||
if (address == null) {
|
||||
CalendarRsvpCard(event)
|
||||
return
|
||||
}
|
||||
|
||||
LoadAddressableNote(address) { appointment ->
|
||||
if (appointment != null) {
|
||||
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
|
||||
if (appointment == null) {
|
||||
CalendarRsvpCard(event)
|
||||
} else {
|
||||
// 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 = { RsvpWithAppointment(event, appointment, accountViewModel, nav) },
|
||||
onBlank = { CalendarRsvpCard(event) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The appointment drawn *inside* the RSVP's frame, under its status strip: cover image full
|
||||
* width, then the same date badge and text lines the calendar list uses. No author header — the
|
||||
* post already says who answered — and no card of its own, so there is one frame, not two.
|
||||
* "In 3 days" moves up into the strip, next to the answer it qualifies.
|
||||
*/
|
||||
@Composable
|
||||
private fun RsvpWithAppointment(
|
||||
event: CalendarRSVPEvent,
|
||||
appointment: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val view = appointment.appointmentView()
|
||||
if (view == null) {
|
||||
CalendarRsvpCard(event)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val appointmentEvent = appointment.event
|
||||
val range = remember(appointmentEvent) { formatCalendarRange(appointment, context) }
|
||||
val relative = rememberRelativeTimeLabel(view, appointmentEvent?.id)
|
||||
val route = remember(appointment) { detailRouteFor(appointment) }
|
||||
|
||||
CalendarRsvpCard(
|
||||
event = event,
|
||||
statusDetail = relative,
|
||||
onClick = { nav.nav(route) },
|
||||
) {
|
||||
val image = view.image
|
||||
if (!image.isNullOrBlank()) {
|
||||
MyAsyncImage(
|
||||
imageUrl = image,
|
||||
contentDescription = view.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
mainImageModifier = CoverImageModifier,
|
||||
loadedImageModifier = Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onLoadingBackground = { Box(CoverImageModifier) },
|
||||
onError = null,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
CalendarDateBadge(view.startSeconds)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CalendarAppointmentLines(view, range, relative = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CoverImageModifier = Modifier.fillMaxWidth().aspectRatio(2f)
|
||||
|
||||
// --------------------------------------------------------------------------------------------
|
||||
// Previews
|
||||
// --------------------------------------------------------------------------------------------
|
||||
|
||||
private const val PREVIEW_HOST = "c07b08396b7bffdb659f862dd7ead57ae169caea65ed573d161e13c1cd6d490c"
|
||||
private const val PREVIEW_FRIEND = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
|
||||
private fun previewConference() =
|
||||
CalendarTimeSlotEvent(
|
||||
id = "5c1a0e3b8f2d4a6e9b7c1d3f5a7e9c2b4d6f8a1c3e5b7d9f2a4c6e8b1d3f5a7e",
|
||||
pubKey = PREVIEW_HOST,
|
||||
createdAt = 1788000000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "nostr-conference-2026"),
|
||||
arrayOf("title", "Nostr Conference 2026"),
|
||||
arrayOf("image", "https://image.nostr.build/conference-cover.jpg"),
|
||||
arrayOf("start", "1791622800"),
|
||||
arrayOf("end", "1791655200"),
|
||||
arrayOf("start_tzid", "Europe/Lisbon"),
|
||||
arrayOf("location", "Lisbon, Portugal"),
|
||||
),
|
||||
content = "Two days of talks, workshops and hacking on the protocol.",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
private fun previewMeetup() =
|
||||
CalendarDateSlotEvent(
|
||||
id = "8e2c4a6f1b3d5e7a9c2e4b6d8f1a3c5e7b9d2f4a6c8e1b3d5f7a9c2e4b6d8f1a",
|
||||
pubKey = PREVIEW_HOST,
|
||||
createdAt = 1788000000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "bitcoin-meetup"),
|
||||
arrayOf("title", "Bitcoin Meetup: open discussion"),
|
||||
arrayOf("start", "2026-10-24"),
|
||||
arrayOf("location", "Restaurant Bjørk, Bodø"),
|
||||
),
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
private fun previewRsvp(
|
||||
id: String,
|
||||
target: Address,
|
||||
status: RSVPStatusTag.STATUS,
|
||||
comment: String,
|
||||
) = CalendarRSVPEvent(
|
||||
id = id,
|
||||
pubKey = PREVIEW_FRIEND,
|
||||
createdAt = 1789000000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "rsvp-$id"),
|
||||
arrayOf("a", target.toValue()),
|
||||
status.toTagArray(),
|
||||
arrayOf("p", target.pubKeyHex),
|
||||
),
|
||||
content = comment,
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PreviewRsvpOf(
|
||||
appointment: Event,
|
||||
rsvp: CalendarRSVPEvent,
|
||||
) {
|
||||
LocalCache.justConsume(appointment, null, true)
|
||||
LocalCache.justConsume(rsvp, null, true)
|
||||
val note = LocalCache.getOrCreateNote(rsvp.id)
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderCalendarRSVPEvent(note, mockAccountViewModel(), EmptyNav())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun RenderCalendarRsvpGoingPreview() {
|
||||
val conference = previewConference()
|
||||
PreviewRsvpOf(
|
||||
conference,
|
||||
previewRsvp(
|
||||
id = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
|
||||
target = conference.address(),
|
||||
status = RSVPStatusTag.STATUS.ACCEPTED,
|
||||
comment = "See you all there!",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun RenderCalendarRsvpMaybeNoImagePreview() {
|
||||
val meetup = previewMeetup()
|
||||
PreviewRsvpOf(
|
||||
meetup,
|
||||
previewRsvp(
|
||||
id = "b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1",
|
||||
target = meetup.address(),
|
||||
status = RSVPStatusTag.STATUS.TENTATIVE,
|
||||
comment = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The appointment has not arrived yet: the status strip over the loading line. */
|
||||
@Composable
|
||||
@Preview
|
||||
fun CalendarRsvpCardLoadingPreview() {
|
||||
val rsvp =
|
||||
previewRsvp(
|
||||
id = "c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2",
|
||||
target = Address(CalendarTimeSlotEvent.KIND, PREVIEW_HOST, "not-fetched-yet"),
|
||||
status = RSVPStatusTag.STATUS.ACCEPTED,
|
||||
comment = "Who else is coming?",
|
||||
)
|
||||
|
||||
ThemeComparisonColumn {
|
||||
CalendarRsvpCard(rsvp)
|
||||
}
|
||||
}
|
||||
|
||||
+65
-46
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.navigation.Route
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.CalendarAppointmentView
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
@@ -75,6 +76,8 @@ fun CalendarEventListCard(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
// Drawn inside the card under the appointment, for lenses that annotate it (who's going).
|
||||
footer: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val view = note.appointmentView() ?: return
|
||||
val context = LocalContext.current
|
||||
@@ -118,51 +121,7 @@ fun CalendarEventListCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
view.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
range?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
relative?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
view.location?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
CalendarAppointmentLines(view, range, relative)
|
||||
val image = view.image
|
||||
val summary = view.summary
|
||||
if (!image.isNullOrBlank()) {
|
||||
@@ -189,11 +148,71 @@ fun CalendarEventListCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title, time range, relative time and location of an appointment — the text half of every
|
||||
* calendar row. Shared by the calendar list card and the feed's RSVP card so the two cannot
|
||||
* disagree about how an event reads. Pass a null [relative] where the caller shows it elsewhere.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CalendarAppointmentLines(
|
||||
view: CalendarAppointmentView,
|
||||
range: String?,
|
||||
relative: String?,
|
||||
) {
|
||||
view.title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
range?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
relative?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
view.location?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarDateBadge(startSeconds: Long?) {
|
||||
internal fun CalendarDateBadge(startSeconds: Long?) {
|
||||
if (startSeconds == null) {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 52.dp, height = 60.dp),
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.AppointmentAttendance
|
||||
import com.vitorpamplona.amethyst.commons.nip52Calendar.ui.CalendarEmptyState
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_count
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_follows_going_empty_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.types.UserGallery
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/** Faces drawn on a row before the rest collapse into "+N". */
|
||||
private const val ATTENDEE_FACES = 6
|
||||
|
||||
/**
|
||||
* Keeps a relay request from growing without bound when the list follows people with a long RSVP
|
||||
* history of appointments this device has never seen.
|
||||
*/
|
||||
private const val MAX_APPOINTMENTS_TO_FETCH = 100
|
||||
|
||||
/**
|
||||
* The upcoming events the people in the selected list said they're going to, soonest first, each
|
||||
* with the faces of who's going.
|
||||
*
|
||||
* The RSVPs come in on the screen's calendar subscription (it asks the list's authors for their
|
||||
* kind-31925s). The appointments they point at are usually published by someone else — a
|
||||
* conference organizer the viewer does not follow — so the ones the cache is missing are fetched
|
||||
* here, by address, through the per-note event finder.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarFollowsGoingView(
|
||||
model: CalendarsViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val state by model.followsGoing.collectAsStateWithLifecycle()
|
||||
val going = state
|
||||
if (going == null) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
return
|
||||
}
|
||||
|
||||
FetchMissingAppointments(going.unresolved, accountViewModel)
|
||||
|
||||
if (going.upcoming.isEmpty()) {
|
||||
CalendarEmptyState(
|
||||
title = stringRes(Res.string.calendar_follows_going_empty_title),
|
||||
subtitle = stringRes(Res.string.calendar_follows_going_empty_subtitle),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = model.followsGoingListState,
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(going.upcoming, key = { it.appointment.idHex }) { entry ->
|
||||
CalendarEventListCard(
|
||||
note = entry.appointment,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
footer = { AttendeesRow(entry, accountViewModel, nav) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FetchMissingAppointments(
|
||||
unresolved: List<AddressableNote>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
unresolved.take(MAX_APPOINTMENTS_TO_FETCH).forEach { appointment ->
|
||||
key(appointment.idHex) {
|
||||
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AttendeesRow(
|
||||
entry: AppointmentAttendance,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val shown = remember(entry) { entry.attendees.take(ATTENDEE_FACES).map(LocalCache::getOrCreateUser) }
|
||||
val total = entry.attendees.size
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
UserGallery(shown, total) { user ->
|
||||
UserPicture(user, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = pluralStringRes(Res.plurals.calendar_follows_going_count, total, total),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
-8
@@ -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(
|
||||
@@ -69,8 +71,10 @@ fun CalendarsScreen(
|
||||
// itself goes. See [CalendarsViewModel].
|
||||
val model: CalendarsViewModel = viewModel()
|
||||
model.init(accountViewModel.userProfile().pubkeyHex, feedState)
|
||||
model.bindAttendeeFilter(accountViewModel.account.liveCalendarsFollowLists, accountViewModel.account.hiddenUsers.flow)
|
||||
|
||||
val filterDTag by model.filterDTag.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
@@ -80,19 +84,31 @@ fun CalendarsScreen(
|
||||
onViewModeChange = { model.viewMode = it },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
trailing = {
|
||||
CalendarFilterChip(
|
||||
selectedDTag = filterDTag,
|
||||
onSelect = model::selectCalendar,
|
||||
model = model,
|
||||
)
|
||||
},
|
||||
// The membership filter narrows the viewer's own calendars; the follows lens is
|
||||
// built from other people's RSVPs, so the chip would claim a filter it ignores.
|
||||
trailing =
|
||||
if (model.viewMode == CalendarsViewMode.FOLLOWS_GOING) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
CalendarFilterChip(
|
||||
selectedDTag = filterDTag,
|
||||
onSelect = model::selectCalendar,
|
||||
model = model,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
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)
|
||||
}
|
||||
@@ -109,6 +125,7 @@ fun CalendarsScreen(
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
when (model.viewMode) {
|
||||
CalendarsViewMode.FEED -> CalendarFeedView(feedState, model, accountViewModel, nav)
|
||||
CalendarsViewMode.FOLLOWS_GOING -> CalendarFollowsGoingView(model, accountViewModel, nav)
|
||||
CalendarsViewMode.MONTH -> CalendarMonthView(model, accountViewModel, nav)
|
||||
CalendarsViewMode.WEEK -> CalendarWeekView(model, accountViewModel, nav)
|
||||
CalendarsViewMode.DAY -> CalendarDayView(model, accountViewModel, nav)
|
||||
|
||||
+13
-10
@@ -33,6 +33,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -84,17 +85,12 @@ fun CalendarsTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
CalendarsViewModeTabs(
|
||||
current = viewMode,
|
||||
onChange = onViewModeChange,
|
||||
trailing = trailing,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
CalendarsViewModeTabs(
|
||||
current = viewMode,
|
||||
onChange = onViewModeChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing?.invoke()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,13 +116,19 @@ private fun CalendarsTopNavFilterBar(
|
||||
private fun CalendarsViewModeTabs(
|
||||
current: CalendarsViewMode,
|
||||
onChange: (CalendarsViewMode) -> Unit,
|
||||
trailing: (@Composable () -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// The trailing control scrolls with the lenses instead of being pinned beside them: pinned,
|
||||
// it took its width out of the scrolling part, and once the lenses outgrew what was left
|
||||
// (on an 800px tablet, after "Friends going" joined them) the last lens sat half-hidden
|
||||
// against it with nothing to say the row scrolls. A chip cut by the screen edge does.
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CalendarsViewMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
@@ -143,5 +145,6 @@ private fun CalendarsViewModeTabs(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
)
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
+136
@@ -31,31 +31,49 @@ import androidx.lifecycle.ViewModel
|
||||
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
|
||||
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
|
||||
@@ -260,6 +278,122 @@ class CalendarsViewModel : ViewModel() {
|
||||
UpcomingPastSplit(emptyList(), emptyList()),
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Where the people in the selected list are going
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* What decides whose RSVPs count: the screen's top-nav list and the account's mute state,
|
||||
* handed over as the account's own flows so a list switch or a new mute re-runs the fold.
|
||||
*/
|
||||
private data class AttendeeInputs(
|
||||
val lists: StateFlow<IFeedTopNavFilter>,
|
||||
val hidden: StateFlow<LiveHiddenUsers>,
|
||||
)
|
||||
|
||||
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>,
|
||||
hidden: StateFlow<LiveHiddenUsers>,
|
||||
) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The upcoming appointments the people in the selected list said they're going to, soonest
|
||||
* 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 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.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val followsGoing: StateFlow<FollowsGoing?> =
|
||||
attendeeInputs
|
||||
.filterNotNull()
|
||||
.flatMapLatest { (lists, hidden) ->
|
||||
combine(
|
||||
LocalCache
|
||||
.observeNewEvents<Event>(Filter(kinds = FOLLOWS_GOING_KINDS))
|
||||
.conflate()
|
||||
.map { }
|
||||
.onStart { emit(Unit) },
|
||||
lists,
|
||||
hidden,
|
||||
) { _, 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()
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Paging
|
||||
// ------------------------------------------------------------------------------------------
|
||||
@@ -304,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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
-4
@@ -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,12 +47,19 @@ 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
|
||||
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
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
@@ -87,6 +96,7 @@ class HomeNewThreadFeedFilter(
|
||||
AddressableNormalVideoEvent.KIND,
|
||||
AddressableShortVideoEvent.KIND,
|
||||
EntityRatingEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,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)
|
||||
@@ -124,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,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
|
||||
@@ -174,7 +202,8 @@ class HomeNewThreadFeedFilter(
|
||||
noteEvent is AttestorRecommendationEvent ||
|
||||
noteEvent is AttestorProficiencyEvent ||
|
||||
// A rating with nothing to point at cannot be rendered.
|
||||
(noteEvent is EntityRatingEvent && noteEvent.hasTarget())
|
||||
(noteEvent is EntityRatingEvent && noteEvent.hasTarget()) ||
|
||||
noteEvent.isGoingRsvp(latestRsvps)
|
||||
) &&
|
||||
filterParams.match(noteEvent, it.relays) &&
|
||||
it.isNewThread()
|
||||
@@ -190,3 +219,20 @@ class HomeNewThreadFeedFilter(
|
||||
}
|
||||
}.sortedByDefaultFeedOrder()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(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
|
||||
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 })
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.commons.resources.conversations
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_articles
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_attestations
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_birds
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_chess
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_classifieds
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_comments
|
||||
@@ -183,6 +184,7 @@ private val HOME_FEED_TYPES =
|
||||
HomeFeedTypeUi(HomeFeedType.PODCASTS, Res.string.home_content_type_podcasts, MaterialSymbols.Podcasts),
|
||||
HomeFeedTypeUi(HomeFeedType.FUNDRAISERS, Res.string.home_content_type_fundraisers, MaterialSymbols.Paid),
|
||||
HomeFeedTypeUi(HomeFeedType.RATINGS, Res.string.home_content_type_ratings, MaterialSymbols.Star),
|
||||
HomeFeedTypeUi(HomeFeedType.CALENDAR_RSVPS, Res.string.home_content_type_calendar_rsvps, MaterialSymbols.CalendarMonth),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,6 +42,7 @@ 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.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
|
||||
@@ -108,6 +109,7 @@ enum class HomeFeedType(
|
||||
PODCASTS("podcasts", listOf(PodcastEpisodeEvent.KIND, PodcastMetadataEvent.KIND)),
|
||||
FUNDRAISERS("fundraisers", listOf(FundraiserEvent.KIND)),
|
||||
RATINGS("ratings", listOf(EntityRatingEvent.KIND)),
|
||||
CALENDAR_RSVPS("calendar_rsvps", listOf(CalendarRSVPEvent.KIND)),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
+5
@@ -30,6 +30,11 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
// a second round-trip, but they don't drive the timeline DAL.
|
||||
val CalendarAppointmentKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND)
|
||||
|
||||
// What an author publishes as the owner of a calendar, as opposed to answering someone else's.
|
||||
val CalendarOwnKinds = listOf(CalendarTimeSlotEvent.KIND, CalendarDateSlotEvent.KIND, CalendarCollectionEvent.KIND)
|
||||
|
||||
val CalendarRsvpKinds = listOf(CalendarRSVPEvent.KIND)
|
||||
|
||||
val AllCalendarKinds =
|
||||
listOf(
|
||||
CalendarTimeSlotEvent.KIND,
|
||||
|
||||
+15
-1
@@ -42,7 +42,21 @@ fun filterCalendarsByAuthors(
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.TOPIC_FEED,
|
||||
authors = authorList,
|
||||
kinds = AllCalendarKinds,
|
||||
kinds = CalendarOwnKinds,
|
||||
limit = 500,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
// RSVPs get their own limit: they are what the "follows going" lens is built from, and
|
||||
// sharing one 500-event window with every appointment and calendar these authors ever
|
||||
// published let a busy organizer starve out everyone else's answers.
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.TOPIC_FEED,
|
||||
authors = authorList,
|
||||
kinds = CalendarRsvpKinds,
|
||||
limit = 500,
|
||||
since = since,
|
||||
),
|
||||
|
||||
+4
@@ -42,6 +42,7 @@ 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.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip54Wiki.WikiArticleEvent
|
||||
@@ -79,6 +80,9 @@ val HomePostsNewThreadKinds1 =
|
||||
VideoShortEvent.KIND,
|
||||
AddressableNormalVideoEvent.KIND,
|
||||
AddressableShortVideoEvent.KIND,
|
||||
// "Going to <event>" from people you follow. Low-volume per author, so it rides the main
|
||||
// leg instead of the 5-item rare-kinds leg below, where it would be starved by the others.
|
||||
CalendarRSVPEvent.KIND,
|
||||
)
|
||||
|
||||
val HomePostsNewThreadKinds2 =
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
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, 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
|
||||
data class FollowsGoing(
|
||||
val upcoming: List<AppointmentAttendance>,
|
||||
val unresolved: List<AddressableNote>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Folds a pile of kind-31925 RSVPs into the appointments the accepted authors are going to.
|
||||
*
|
||||
* - 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); 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.
|
||||
*
|
||||
* [nowSeconds] is a parameter so the split is deterministic under test.
|
||||
*/
|
||||
fun computeFollowsGoing(
|
||||
rsvps: Collection<CalendarRSVPEvent>,
|
||||
isAttendee: (CalendarRSVPEvent) -> Boolean,
|
||||
appointmentFor: (Address) -> AddressableNote,
|
||||
nowSeconds: Long,
|
||||
isAppointmentVisible: (Address) -> Boolean = { true },
|
||||
): FollowsGoing {
|
||||
val latest = latestRsvpAnswers(rsvps)
|
||||
|
||||
val goingByAppointment = HashMap<Address, MutableList<CalendarRSVPEvent>>()
|
||||
latest.forEach { (key, rsvp) ->
|
||||
if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED && isAttendee(rsvp)) {
|
||||
goingByAppointment.getOrPut(key.first) { mutableListOf() }.add(rsvp)
|
||||
}
|
||||
}
|
||||
|
||||
val upcoming = ArrayList<Pair<Long, AppointmentAttendance>>()
|
||||
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(going.first().createdAt to appointment)
|
||||
return@forEach
|
||||
}
|
||||
val start = appointment.calendarStartSeconds() ?: return@forEach
|
||||
val end = appointment.calendarEndSeconds() ?: start
|
||||
if (end < nowSeconds) return@forEach
|
||||
|
||||
upcoming.add(start to AppointmentAttendance(appointment, going.map { it.pubKey }))
|
||||
}
|
||||
|
||||
upcoming.sortWith(
|
||||
compareBy<Pair<Long, AppointmentAttendance>> { it.first }
|
||||
.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.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 }
|
||||
|
||||
private fun CalendarRSVPEvent.isNewerThan(other: CalendarRSVPEvent) = NEWEST_FIRST.compare(this, other) < 0
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip52Calendar
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
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 {
|
||||
private val now = 1_800_000_000L
|
||||
private val host = "a".repeat(64)
|
||||
private val alice = "1".repeat(64)
|
||||
private val bob = "2".repeat(64)
|
||||
private val carol = "3".repeat(64)
|
||||
|
||||
private val notes = HashMap<Address, AddressableNote>()
|
||||
|
||||
private fun appointmentFor(address: Address) = notes.getOrPut(address) { AddressableNote(address) }
|
||||
|
||||
private fun timeSlot(
|
||||
dTag: String,
|
||||
start: Long,
|
||||
end: Long? = null,
|
||||
): Address {
|
||||
val address = Address(CalendarTimeSlotEvent.KIND, host, dTag)
|
||||
val tags =
|
||||
buildList {
|
||||
add(arrayOf("d", dTag))
|
||||
add(arrayOf("title", dTag))
|
||||
add(arrayOf("start", start.toString()))
|
||||
end?.let { add(arrayOf("end", it.toString())) }
|
||||
}.toTypedArray()
|
||||
appointmentFor(address).event = CalendarTimeSlotEvent("id-$dTag", host, 0L, tags, "", "sig")
|
||||
return address
|
||||
}
|
||||
|
||||
private var rsvpCounter = 0
|
||||
|
||||
private fun rsvp(
|
||||
author: String,
|
||||
target: Address,
|
||||
status: String,
|
||||
createdAt: Long,
|
||||
dTag: String = "rsvp-${rsvpCounter++}",
|
||||
) = CalendarRSVPEvent(
|
||||
id = "rsvp-${rsvpCounter++}",
|
||||
pubKey = author,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", dTag),
|
||||
arrayOf("a", target.toValue()),
|
||||
arrayOf("status", status),
|
||||
),
|
||||
content = "",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
private fun fold(
|
||||
rsvps: List<CalendarRSVPEvent>,
|
||||
isAttendee: (CalendarRSVPEvent) -> Boolean = { true },
|
||||
) = computeFollowsGoing(rsvps, isAttendee, ::appointmentFor, now)
|
||||
|
||||
@Test
|
||||
fun groupsGoingAuthorsPerAppointment_newestAnswerFirst() {
|
||||
val conf = timeSlot("conf", start = now + 86_400)
|
||||
|
||||
val result =
|
||||
fold(
|
||||
listOf(
|
||||
rsvp(alice, conf, "accepted", createdAt = 10),
|
||||
rsvp(bob, conf, "accepted", createdAt = 20),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(1, result.upcoming.size)
|
||||
assertEquals(listOf(bob, alice), result.upcoming.single().attendees)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyTheLatestAnswerPerAuthorCounts_evenAcrossDTags() {
|
||||
val conf = timeSlot("conf", start = now + 86_400)
|
||||
|
||||
// Alice said going, then changed her mind in a second RSVP with a different d tag.
|
||||
val result =
|
||||
fold(
|
||||
listOf(
|
||||
rsvp(alice, conf, "accepted", createdAt = 10, dTag = "first"),
|
||||
rsvp(alice, conf, "declined", createdAt = 20, dTag = "second"),
|
||||
rsvp(bob, conf, "tentative", createdAt = 30),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(result.upcoming.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attendeeFilterRunsOnTheLatestAnswer() {
|
||||
val conf = timeSlot("conf", start = now + 86_400)
|
||||
|
||||
val result =
|
||||
fold(
|
||||
listOf(
|
||||
rsvp(alice, conf, "accepted", createdAt = 10),
|
||||
rsvp(carol, conf, "accepted", createdAt = 20),
|
||||
),
|
||||
isAttendee = { it.pubKey != carol },
|
||||
)
|
||||
|
||||
assertEquals(listOf(alice), result.upcoming.single().attendees)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropsEndedEvents_keepsOngoingOnes_sortsSoonestFirst() {
|
||||
val past = timeSlot("past", start = now - 7_200, end = now - 3_600)
|
||||
val ongoing = timeSlot("ongoing", start = now - 3_600, end = now + 3_600)
|
||||
val later = timeSlot("later", start = now + 10 * 86_400)
|
||||
val sooner = timeSlot("sooner", start = now + 86_400)
|
||||
|
||||
val result =
|
||||
fold(
|
||||
listOf(past, ongoing, later, sooner).map { rsvp(alice, it, "accepted", createdAt = 10) },
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(ongoing, sooner, later),
|
||||
result.upcoming.map { it.appointment.address },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appointmentsNotInTheCacheAreReportedForFetching() {
|
||||
val missing = Address(CalendarDateSlotEvent.KIND, host, "not-loaded")
|
||||
|
||||
val result = fold(listOf(rsvp(alice, missing, "accepted", createdAt = 10)))
|
||||
|
||||
assertTrue(result.upcoming.isEmpty())
|
||||
assertEquals(listOf(missing), result.unresolved.map { it.address })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresRsvpsThatPointAtNonAppointments() {
|
||||
val calendar = Address(CalendarCollectionEvent.KIND, host, "cal")
|
||||
|
||||
val result = fold(listOf(rsvp(alice, calendar, "accepted", createdAt = 10)))
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,7 @@
|
||||
<string name="relay_discovery_geohash">Location</string>
|
||||
<string name="calendar_rsvp_going">Going</string>
|
||||
<string name="calendar_rsvp_maybe">Maybe</string>
|
||||
<string name="calendar_rsvp_loading_event">Loading event…</string>
|
||||
<string name="calendar_rsvp_not_going">Can't go</string>
|
||||
<plurals name="calendar_collection_count">
|
||||
<item quantity="one">%1$d event</item>
|
||||
@@ -3461,6 +3462,13 @@
|
||||
<string name="calendar_reminder_settings_title">Calendar reminders</string>
|
||||
<string name="calendar_view_day">Day</string>
|
||||
<string name="calendar_view_feed">Feed</string>
|
||||
<string name="calendar_view_follows_going">Friends going</string>
|
||||
<string name="calendar_follows_going_empty_title">No plans yet</string>
|
||||
<string name="calendar_follows_going_empty_subtitle">When people in this list RSVP “Going” to an upcoming event, it shows up here.</string>
|
||||
<plurals name="calendar_follows_going_count">
|
||||
<item quantity="one">%1$d going</item>
|
||||
<item quantity="other">%1$d going</item>
|
||||
</plurals>
|
||||
<string name="calendar_view_month">Month</string>
|
||||
<string name="calendar_view_week">Week</string>
|
||||
<string name="call_accept">Accept</string>
|
||||
@@ -3908,6 +3916,7 @@
|
||||
<string name="home_content_type_podcasts">Podcasts</string>
|
||||
<string name="home_content_type_polls">Polls</string>
|
||||
<string name="home_content_type_ratings">Ratings and reviews</string>
|
||||
<string name="home_content_type_calendar_rsvps">Event RSVPs</string>
|
||||
<string name="home_content_type_reposts">Reposts</string>
|
||||
<string name="home_content_type_shorts">Shorts</string>
|
||||
<string name="home_content_type_text_notes">Text notes</string>
|
||||
|
||||
+5
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.nip52Calendar.ui
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_view_day
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_view_feed
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_view_follows_going
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_view_month
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_view_week
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
@@ -31,11 +32,15 @@ import org.jetbrains.compose.resources.StringResource
|
||||
* Lenses on the same appointment timeline. Calendar *collections* (kind 31924) live on their
|
||||
* own screen ([CalendarCollectionsScreen]) since they're a sibling feed, not a different view
|
||||
* of the appointment data.
|
||||
*
|
||||
* [FOLLOWS_GOING] is the one lens not drawn from the appointment timeline: it is built from the
|
||||
* RSVPs of the people in the selected list, showing the upcoming events they said they'd attend.
|
||||
*/
|
||||
enum class CalendarsViewMode(
|
||||
val labelRes: StringResource,
|
||||
) {
|
||||
FEED(Res.string.calendar_view_feed),
|
||||
FOLLOWS_GOING(Res.string.calendar_view_follows_going),
|
||||
MONTH(Res.string.calendar_view_month),
|
||||
WEEK(Res.string.calendar_view_week),
|
||||
DAY(Res.string.calendar_view_day),
|
||||
|
||||
+108
-54
@@ -20,7 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.note
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -34,84 +37,135 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_going
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_loading_event
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_maybe
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_rsvp_not_going
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/** Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target. */
|
||||
/**
|
||||
* A NIP-52 calendar RSVP (kind 31925) as one card: the author's note as post text, then a single
|
||||
* quote-style frame whose tinted strip says how they answered and whose body is the appointment
|
||||
* they answered.
|
||||
*
|
||||
* The status lives *inside* the appointment's frame on purpose. It is a property of "Alice → this
|
||||
* event", so a header line floating above a separately framed event read as two unrelated posts;
|
||||
* and the frame is the feed's plain quote border, not a raised card, so the RSVP never nests a
|
||||
* box inside a box.
|
||||
*
|
||||
* [appointment] draws the event once the front end has it (no header of its own — the post's
|
||||
* author is who answered, and a second avatar row made it read like a quote of a quote). Until
|
||||
* then the strip stands over a muted "loading" line. [statusDetail] is the strip's trailing text,
|
||||
* e.g. "in 3 days". [onClick] makes the whole frame open the event.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarRsvpCard(event: CalendarRSVPEvent) {
|
||||
fun CalendarRsvpCard(
|
||||
event: CalendarRSVPEvent,
|
||||
statusDetail: String? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
appointment: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
val status = remember(event) { event.status() }
|
||||
val targetAddress = remember(event) { event.calendarEventAddress() }
|
||||
val freebusy = remember(event) { event.freebusy() }
|
||||
|
||||
val statusLabel =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED -> stringResource(Res.string.calendar_rsvp_going)
|
||||
RSVPStatusTag.STATUS.TENTATIVE -> stringResource(Res.string.calendar_rsvp_maybe)
|
||||
RSVPStatusTag.STATUS.DECLINED -> stringResource(Res.string.calendar_rsvp_not_going)
|
||||
null -> "—"
|
||||
}
|
||||
|
||||
val statusColor =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
|
||||
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
|
||||
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
|
||||
null -> Color.Gray
|
||||
}
|
||||
|
||||
Column(MaterialTheme.colorScheme.replyModifier) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CalendarMonth,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = statusColor,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = statusLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = statusColor,
|
||||
)
|
||||
val frame =
|
||||
if (onClick != null) {
|
||||
MaterialTheme.colorScheme.replyModifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.replyModifier
|
||||
}
|
||||
|
||||
Column {
|
||||
if (event.content.isNotBlank()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = event.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
targetAddress?.let { addr ->
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = "→ ${addr.toValue()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
Column(frame) {
|
||||
RsvpStatusStrip(status, statusDetail)
|
||||
|
||||
if (freebusy != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
if (appointment != null) {
|
||||
appointment()
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.calendar_rsvp_loading_event),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RsvpStatusStrip(
|
||||
status: RSVPStatusTag.STATUS?,
|
||||
detail: String?,
|
||||
) {
|
||||
val (label, color, symbol) = rsvpStatusLook(status)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(color.copy(alpha = 0.12f))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = color,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = color,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class RsvpStatusLook(
|
||||
val label: String,
|
||||
val color: Color,
|
||||
val symbol: MaterialSymbol,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rsvpStatusLook(status: RSVPStatusTag.STATUS?): RsvpStatusLook =
|
||||
when (status) {
|
||||
RSVPStatusTag.STATUS.ACCEPTED ->
|
||||
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_going), MaterialTheme.colorScheme.primary, MaterialSymbols.CheckCircle)
|
||||
RSVPStatusTag.STATUS.TENTATIVE ->
|
||||
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_maybe), MaterialTheme.colorScheme.tertiary, MaterialSymbols.Schedule)
|
||||
RSVPStatusTag.STATUS.DECLINED ->
|
||||
RsvpStatusLook(stringResource(Res.string.calendar_rsvp_not_going), MaterialTheme.colorScheme.error, MaterialSymbols.Cancel)
|
||||
null ->
|
||||
RsvpStatusLook("—", Color.Gray, MaterialSymbols.CalendarMonth)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user