Merge pull request #4258 from vitorpamplona/claude/zealous-meitner-399jm4

Calendar: show appointments inside RSVP cards with attendee faces
This commit is contained in:
Vitor Pamplona
2026-09-28 19:58:33 -04:00
committed by GitHub
17 changed files with 1237 additions and 148 deletions
@@ -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)
}
}
@@ -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),
@@ -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,
)
}
}
@@ -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)
@@ -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()
}
}
@@ -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
}
}
@@ -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 })
@@ -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 {
@@ -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,
@@ -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,
),
@@ -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 =
@@ -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
@@ -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>
@@ -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),
@@ -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)
}