feat(calendar): merge the RSVP card and its event into one frame

The Home feed's Going card stacked a bordered box, a status title, the
comment, and a raised, shadowed event card carrying the host's own avatar
header: two frames and two authors for one post.

Now the note's text is the RSVP comment, followed by a single quote-style
frame: a tinted status strip (Going / Maybe / Can't go, with "in 3 days"
on the right), the cover image full width, and the same date badge and
text lines the calendar list uses. No inner card, no second header; the
whole frame opens the event. While the event loads the strip sits over a
muted loading line instead of the raw a-tag address.

The list card's badge and text lines are extracted so both surfaces draw
an event the same way. Icons reuse existing codepoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BfJPuJUrcXrZqSyVevxKbk
This commit is contained in:
Claude
2026-09-28 20:14:00 +00:00
parent f409df6643
commit c082b006f8
4 changed files with 244 additions and 110 deletions
@@ -20,21 +20,43 @@
*/
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.unit.dp
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.CalendarRsvpCard
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.CalendarEventListCard
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.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] with that appointment inside it
* — "going" is only interesting next to *what* — falling back to the bare address until it loads.
* 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
@@ -74,14 +96,69 @@ fun RenderCalendarRSVPEvent(
// keep asking relays for the appointment's reactions and zaps on every RSVP in the feed.
WatchNoteEvent(
baseNote = appointment,
onNoteEventFound = {
CalendarRsvpCard(event) {
CalendarEventListCard(appointment, accountViewModel, nav)
}
},
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)
@@ -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
@@ -120,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()) {
@@ -196,8 +153,66 @@ fun CalendarEventListCard(
}
}
/**
* 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
private fun CalendarDateBadge(startSeconds: Long?) {
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
internal fun CalendarDateBadge(startSeconds: Long?) {
if (startSeconds == null) {
Box(
modifier = Modifier.size(width = 52.dp, height = 60.dp),
@@ -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>
@@ -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,97 +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.
*
* [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.
* 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,
target: (@Composable () -> Unit)? = null,
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),
)
}
if (target != null) {
Spacer(modifier = StdVertSpacer)
target()
} else {
targetAddress?.let { addr ->
Spacer(modifier = StdVertSpacer)
Column(frame) {
RsvpStatusStrip(status, statusDetail)
if (appointment != null) {
appointment()
} else {
Text(
text = "→ ${addr.toValue()}",
style = MaterialTheme.typography.labelSmall,
text = stringResource(Res.string.calendar_rsvp_loading_event),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 12.dp),
modifier = Modifier.padding(12.dp),
)
}
}
}
}
if (freebusy != null) {
Spacer(modifier = StdVertSpacer)
@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)
}