mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
feat(calendar): show follows' RSVPs on Home and add a Friends going lens
- Home: accepted NIP-52 RSVPs (kind 31925) from the selected list now load and render in New Threads, with a Settings > Home toggle (Event RSVPs). The RSVP card embeds the appointment it answers instead of its raw address. - Calendars: new "Friends going" lens lists the upcoming events the people in the selected list RSVP'd going to, soonest first, with their faces. Appointments missing from the cache are fetched by address. - Calendar author subscription asks for RSVPs on their own limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk
This commit is contained in:
+28
-22
@@ -27,29 +27,15 @@ 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.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.CalendarEventListCard
|
||||
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 inside it
|
||||
* — "going" is only interesting next to *what* — falling back to the bare address until it loads.
|
||||
*
|
||||
* [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
|
||||
@@ -67,15 +53,35 @@ 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) {
|
||||
if (appointment == null) {
|
||||
CalendarRsvpCard(event)
|
||||
} else {
|
||||
EventFinderFilterAssemblerSubscription(appointment, accountViewModel)
|
||||
|
||||
WatchNoteEvent(
|
||||
baseNote = appointment,
|
||||
onNoteEventFound = {
|
||||
CalendarRsvpCard(event) {
|
||||
CalendarEventListCard(appointment, accountViewModel, nav)
|
||||
}
|
||||
},
|
||||
onBlank = { CalendarRsvpCard(event) },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -75,6 +75,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
|
||||
@@ -189,6 +191,8 @@ fun CalendarEventListCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -69,6 +69,7 @@ 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()
|
||||
|
||||
@@ -80,13 +81,20 @@ 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 = {
|
||||
@@ -109,6 +117,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)
|
||||
|
||||
+78
@@ -30,6 +30,16 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedContentState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FilterByListParams
|
||||
import com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.FollowsGoing
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.computeFollowsGoing
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.calendars.CalendarAppointmentKinds
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import com.vitorpamplona.amethyst.commons.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
@@ -260,6 +270,74 @@ 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)
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 two cache observers: every kind-31925 (the RSVPs themselves) and every new
|
||||
* appointment, because an RSVP usually lands before the appointment it answers — the author
|
||||
* of the appointment is often someone the viewer does not follow — and the row can only be
|
||||
* placed once the appointment's date is known. Both kinds are low-volume.
|
||||
*
|
||||
* `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.observeEvents<CalendarRSVPEvent>(Filter(kinds = listOf(CalendarRSVPEvent.KIND))),
|
||||
LocalCache
|
||||
.observeNewEvents<Event>(Filter(kinds = CalendarAppointmentKinds))
|
||||
.map { }
|
||||
.onStart { emit(Unit) },
|
||||
lists,
|
||||
hidden,
|
||||
) { rsvps, _, list, hiddenUsers ->
|
||||
val params = FilterByListParams.create(list, hiddenUsers)
|
||||
computeFollowsGoing(
|
||||
rsvps = rsvps,
|
||||
isAttendee = { rsvp ->
|
||||
// Relays only matter to a relay-scoped list; they live on the canonical note.
|
||||
val relays = LocalCache.getAddressableNoteIfExists(rsvp.address())?.relays ?: emptyList()
|
||||
params.match(rsvp, relays)
|
||||
},
|
||||
appointmentFor = LocalCache::getOrCreateAddressableNote,
|
||||
nowSeconds = TimeUtils.now(),
|
||||
)
|
||||
}
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
|
||||
|
||||
val followsGoingListState = LazyListState()
|
||||
|
||||
// ------------------------------------------------------------------------------------------
|
||||
// Paging
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
+15
-1
@@ -45,12 +45,15 @@ 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.Event
|
||||
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.tags.RSVPStatusTag
|
||||
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 +90,7 @@ class HomeNewThreadFeedFilter(
|
||||
AddressableNormalVideoEvent.KIND,
|
||||
AddressableShortVideoEvent.KIND,
|
||||
EntityRatingEvent.KIND,
|
||||
CalendarRSVPEvent.KIND,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,7 +178,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()
|
||||
) &&
|
||||
filterParams.match(noteEvent, it.relays) &&
|
||||
it.isNewThread()
|
||||
@@ -190,3 +195,12 @@ 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.
|
||||
*/
|
||||
private fun Event.isGoingRsvp() =
|
||||
this is CalendarRSVPEvent &&
|
||||
status() == RSVPStatusTag.STATUS.ACCEPTED &&
|
||||
calendarEventAddress() != null
|
||||
|
||||
+2
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.resources.home_content_type_nips
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_pictures
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_podcasts
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_polls
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_calendar_rsvps
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_ratings
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_reposts
|
||||
import com.vitorpamplona.amethyst.commons.resources.home_content_type_shorts
|
||||
@@ -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 =
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
@Immutable
|
||||
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: 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.
|
||||
*/
|
||||
@Immutable
|
||||
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) or an old version still in the cache; the newest one is their answer.
|
||||
* - [isAttendee] runs on that latest RSVP only: the follow-list / mute check belongs to the caller.
|
||||
* - 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,
|
||||
): FollowsGoing {
|
||||
// (appointment, author) -> that author's newest answer to it
|
||||
val latest = HashMap<Pair<Address, HexKey>, CalendarRSVPEvent>()
|
||||
rsvps.forEach { rsvp ->
|
||||
val target = rsvp.calendarEventAddress() ?: return@forEach
|
||||
if (target.kind != CalendarTimeSlotEvent.KIND && target.kind != CalendarDateSlotEvent.KIND) return@forEach
|
||||
val key = target to rsvp.pubKey
|
||||
val current = latest[key]
|
||||
if (current == null || rsvp.createdAt > current.createdAt) {
|
||||
latest[key] = rsvp
|
||||
}
|
||||
}
|
||||
|
||||
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<AddressableNote>()
|
||||
|
||||
goingByAppointment.forEach { (address, going) ->
|
||||
val appointment = appointmentFor(address)
|
||||
if (appointment.event == null) {
|
||||
unresolved.add(appointment)
|
||||
return@forEach
|
||||
}
|
||||
val start = appointment.calendarStartSeconds() ?: return@forEach
|
||||
val end = appointment.calendarEndSeconds() ?: start
|
||||
if (end < nowSeconds) return@forEach
|
||||
|
||||
val attendees = going.sortedByDescending { it.createdAt }.map { it.pubKey }
|
||||
upcoming.add(start to AppointmentAttendance(appointment, attendees))
|
||||
}
|
||||
|
||||
upcoming.sortWith(
|
||||
compareBy<Pair<Long, AppointmentAttendance>> { it.first }
|
||||
.thenByDescending { it.second.attendees.size }
|
||||
.thenBy { it.second.appointment.idHex },
|
||||
)
|
||||
|
||||
return FollowsGoing(upcoming.map { it.second }, unresolved)
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* 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.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())
|
||||
}
|
||||
}
|
||||
@@ -3458,6 +3458,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>
|
||||
@@ -3905,6 +3912,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),
|
||||
|
||||
+22
-9
@@ -47,9 +47,17 @@ 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. */
|
||||
/**
|
||||
* Card for a NIP-52 calendar RSVP (kind 31925): the going/maybe/not-going status, note, and target.
|
||||
*
|
||||
* [target] draws the appointment being answered once the front end has it; until then (or when
|
||||
* it is null) the card falls back to printing the appointment's address.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarRsvpCard(event: CalendarRSVPEvent) {
|
||||
fun CalendarRsvpCard(
|
||||
event: CalendarRSVPEvent,
|
||||
target: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val status = remember(event) { event.status() }
|
||||
val targetAddress = remember(event) { event.calendarEventAddress() }
|
||||
val freebusy = remember(event) { event.freebusy() }
|
||||
@@ -100,14 +108,19 @@ fun CalendarRsvpCard(event: CalendarRSVPEvent) {
|
||||
)
|
||||
}
|
||||
|
||||
targetAddress?.let { addr ->
|
||||
if (target != null) {
|
||||
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),
|
||||
)
|
||||
target()
|
||||
} else {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (freebusy != null) {
|
||||
|
||||
Reference in New Issue
Block a user