feat(calendars): RSVP dedupe + collection editing with event picker

RSVP (item 4):
- CalendarRsvpRow now uses a deterministic d-tag of the form
  'rsvp:<kind>:<pubkey>:<dtag>' derived from the target appointment's
  address. Each tap replaces the user's single addressable RSVP for that
  event rather than appending another, eliminating the previous footgun
  where rapid taps spammed relays with parallel kind-31925 events.
- Buttons now reflect the current RSVP status reactively: the matching
  status renders as filled-tonal with the status colour; the others
  render outlined. Reads `LocalCache.getOrCreateAddressableNote()` and
  observes the metadata flow so the row updates as soon as the new
  event lands in cache (own broadcast or relay echo).

Collections (item 5):
- Lifted NewCalendarCollectionScreen onto NewCalendarCollectionViewModel
  with title/description/selected-event state. Editing mode pre-populates
  from an existing kind-31924 by dTag (already supported by
  Route.NewCalendarCollection but previously unused), preserves the
  d-tag so the publish replaces the addressable, and seeds the
  selected-event list from the existing `a` tags.
- Added a multi-select picker that lists the user's own appointments
  (kinds 31922/31923 authored by `account.userProfile()`), sorted
  upcoming-first. Toggling a row adds or removes its address from the
  outgoing `a` tag list.
- Wired Route.NewCalendarCollection.dTag through to the screen so the
  edit flow becomes reachable from anywhere that has the calendar's
  dTag (the event-detail screen will plug into this in a follow-up).

https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U
This commit is contained in:
Claude
2026-05-19 21:58:32 +00:00
parent 447301599c
commit e901364e87
5 changed files with 373 additions and 52 deletions
@@ -259,7 +259,7 @@ fun BuildNavigation(
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.NewCalendarEvent> { NewCalendarEventScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel) }
composableFromBottomArgs<Route.NewCalendarCollection> { NewCalendarCollectionScreen(nav, accountViewModel, it.dTag) }
composableFromEnd<Route.Products> { ProductsScreen(accountViewModel, nav) }
composableFromEnd<Route.Shorts> { ShortsScreen(accountViewModel, nav) }
composableFromEnd<Route.PublicChats> { PublicChatsScreen(accountViewModel, nav) }
@@ -30,21 +30,29 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
/**
* Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar event.
* Tapping a button publishes a new kind 31925 with a random `d` tag — multiple taps create
* multiple RSVPs, which is consistent with how the NIP describes "responses".
* Renders a 3-button RSVP row (Going / Maybe / Can't go) below a NIP-52 calendar appointment.
*
* Uses a deterministic d-tag derived from the target appointment's address so the user has
* exactly one RSVP per event from this client — tapping again replaces it rather than appending
* another addressable. The button matching the current status renders as filled-tonal; the
* others render outlined.
*/
@Composable
fun CalendarRsvpRow(
@@ -54,46 +62,117 @@ fun CalendarRsvpRow(
eventId: String,
accountViewModel: AccountViewModel,
) {
val myPubKey = accountViewModel.userProfile().pubkeyHex
val targetAddress = remember(eventKind, eventPubKey, eventDTag) { Address(eventKind, eventPubKey, eventDTag) }
val myRsvpAddress = remember(targetAddress, myPubKey) { rsvpAddressFor(myPubKey, targetAddress) }
val myRsvpNote = remember(myRsvpAddress) { LocalCache.getOrCreateAddressableNote(myRsvpAddress) }
val myRsvpState by myRsvpNote
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val currentStatus = (myRsvpState.note.event as? CalendarRSVPEvent)?.status()
val onTap: (RSVPStatusTag.STATUS) -> Unit = { newStatus ->
sendRsvp(
accountViewModel = accountViewModel,
targetAddress = targetAddress,
eventId = eventId,
myPubKey = myPubKey,
status = newStatus,
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.ACCEPTED) },
RsvpButton(
label = stringRes(R.string.calendar_rsvp_going),
status = RSVPStatusTag.STATUS.ACCEPTED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(R.string.calendar_rsvp_maybe),
status = RSVPStatusTag.STATUS.TENTATIVE,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
RsvpButton(
label = stringRes(R.string.calendar_rsvp_not_going),
status = RSVPStatusTag.STATUS.DECLINED,
currentStatus = currentStatus,
modifier = Modifier.weight(1f),
onClick = onTap,
)
}
}
@Composable
private fun RsvpButton(
label: String,
status: RSVPStatusTag.STATUS,
currentStatus: RSVPStatusTag.STATUS?,
modifier: Modifier,
onClick: (RSVPStatusTag.STATUS) -> Unit,
) {
val selected = status == currentStatus
if (selected) {
FilledTonalButton(
onClick = { onClick(status) },
modifier = modifier,
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
containerColor = colorFor(status),
contentColor = Color.White,
),
) {
Text(text = stringRes(R.string.calendar_rsvp_going))
Text(text = label)
}
} else {
OutlinedButton(
onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.TENTATIVE) },
modifier = Modifier.weight(1f),
onClick = { onClick(status) },
modifier = modifier,
) {
Text(text = stringRes(R.string.calendar_rsvp_maybe))
}
OutlinedButton(
onClick = { sendRsvp(accountViewModel, eventKind, eventPubKey, eventDTag, eventId, RSVPStatusTag.STATUS.DECLINED) },
modifier = Modifier.weight(1f),
) {
Text(text = stringRes(R.string.calendar_rsvp_not_going))
Text(text = label)
}
}
}
@Composable
private fun colorFor(status: RSVPStatusTag.STATUS) =
when (status) {
RSVPStatusTag.STATUS.ACCEPTED -> MaterialTheme.colorScheme.primary
RSVPStatusTag.STATUS.TENTATIVE -> MaterialTheme.colorScheme.tertiary
RSVPStatusTag.STATUS.DECLINED -> MaterialTheme.colorScheme.error
}
/**
* Deterministic per-target d-tag so each user's RSVP for a given event is a single addressable.
* The format mirrors the a-tag coordinate so it's debuggable (`rsvp:31923:<pubkey>:<dtag>`).
*/
fun rsvpDTagFor(targetAddress: Address): String = "rsvp:${targetAddress.kind}:${targetAddress.pubKeyHex}:${targetAddress.dTag}"
fun rsvpAddressFor(
myPubKey: String,
targetAddress: Address,
): Address = Address(CalendarRSVPEvent.KIND, myPubKey, rsvpDTagFor(targetAddress))
private fun sendRsvp(
accountViewModel: AccountViewModel,
eventKind: Int,
eventPubKey: String,
eventDTag: String,
targetAddress: Address,
eventId: String,
myPubKey: String,
status: RSVPStatusTag.STATUS,
) {
val noteRelays = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
val aTag = ATag(eventKind, eventPubKey, eventDTag, noteRelays)
val pTag = PTag(eventPubKey)
val relayHint = LocalCache.getNoteIfExists(eventId)?.relays?.firstOrNull()
val aTag = ATag(targetAddress, relayHint)
val pTag = PTag(targetAddress.pubKeyHex)
val dTag = rsvpDTagFor(targetAddress)
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
@@ -101,6 +180,7 @@ private fun sendRsvp(
calendarEventAddress = aTag,
status = status,
calendarEventAuthor = pTag,
dTag = dTag,
),
)
}
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
@@ -29,54 +31,50 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.formatLongDate
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewCalendarCollectionScreen(
nav: INav,
accountViewModel: AccountViewModel,
editDTag: String? = null,
) {
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
val vm: NewCalendarCollectionViewModel = viewModel()
vm.init(accountViewModel, editDTag)
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.new_calendar_collection,
titleRes = if (editDTag == null) R.string.new_calendar_collection else R.string.edit_calendar_collection,
onCancel = { nav.popBack() },
onPost = {
if (title.isBlank()) {
errorMessage = "title-required"
return@SavingTopBar
}
accountViewModel.launchSigner {
accountViewModel.account.signAndComputeBroadcast(
CalendarEvent.build(
title = title.trim(),
content = description.trim(),
),
)
nav.popBack()
if (vm.publish()) {
nav.popBack()
}
}
},
)
@@ -96,34 +94,112 @@ fun NewCalendarCollectionScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = title,
onValueChange = {
title = it
errorMessage = null
},
value = vm.title.value,
onValueChange = { vm.title.value = it },
label = { Text(stringRes(R.string.calendar_collection_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
isError = errorMessage == "title-required",
isError = !vm.isValid(),
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
value = vm.description.value,
onValueChange = { vm.description.value = it },
label = { Text(stringRes(R.string.calendar_collection_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 5,
minLines = 4,
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
)
if (errorMessage != null) {
if (!vm.isValid()) {
Text(
text = stringRes(R.string.calendar_collection_invalid),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
AppointmentPickerSection(vm)
}
}
}
@Composable
private fun AppointmentPickerSection(vm: NewCalendarCollectionViewModel) {
val available by vm.availableAppointments
val selectedCount = vm.selectedAddresses.size
Text(
text = stringRes(R.string.calendar_collection_events_section, selectedCount),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
if (available.isEmpty()) {
Text(
text = stringRes(R.string.calendar_collection_no_events_yet),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp),
)
return
}
available.forEach { summary ->
// Snapshot selection state without subscribing to the list itself (we only need to
// re-render the affected row on toggle).
val isSelected = vm.selectedAddresses.contains(summary.address)
AppointmentPickerRow(
summary = summary,
isSelected = isSelected,
onToggle = { vm.toggle(summary.address) },
)
HorizontalDivider()
}
}
@Composable
private fun AppointmentPickerRow(
summary: OwnedAppointmentSummary,
isSelected: Boolean,
onToggle: () -> Unit,
) {
val whenLabel =
remember(summary.address, summary.startSeconds, summary.isAllDay) {
when {
summary.isAllDay -> "All-day"
summary.startSeconds != null -> formatLongDate(summary.startSeconds)
else -> ""
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = isSelected, onCheckedChange = { onToggle() })
Column(
modifier = Modifier.padding(start = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = summary.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = whenLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,162 @@
/*
* 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.create
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Lightweight projection of a calendar appointment authored by the current user, used to power
* the multi-select picker on the collection editor.
*/
@Immutable
data class OwnedAppointmentSummary(
val address: Address,
val title: String,
val startSeconds: Long?,
val isAllDay: Boolean,
)
class NewCalendarCollectionViewModel : ViewModel() {
private lateinit var account: Account
val title = mutableStateOf("")
val description = mutableStateOf("")
val isPublishing = mutableStateOf(false)
/** Stable d-tag for the addressable: random for create, preserved when editing. */
private var dTag: String? = null
val selectedAddresses = mutableStateListOf<Address>()
val availableAppointments = mutableStateOf<List<OwnedAppointmentSummary>>(emptyList())
fun init(
accountViewModel: AccountViewModel,
editDTag: String?,
) {
if (::account.isInitialized) return // idempotent across recompositions
this.account = accountViewModel.account
dTag = editDTag
editDTag?.let { existingDTag ->
val existingAddress = Address(CalendarEvent.KIND, account.userProfile().pubkeyHex, existingDTag)
val existingNote = LocalCache.addressables.get(existingAddress)
(existingNote?.event as? CalendarEvent)?.let { existing ->
title.value = existing.title().orEmpty()
description.value = existing.content
selectedAddresses.addAll(existing.calendarEventAddresses())
}
}
availableAppointments.value = loadOwnedAppointments()
}
fun toggle(address: Address) {
if (selectedAddresses.remove(address)) return
selectedAddresses.add(address)
}
fun isValid(): Boolean = title.value.isNotBlank()
suspend fun publish(): Boolean {
if (!isValid()) return false
isPublishing.value = true
try {
val effectiveDTag = dTag
val selected = selectedAddresses.toList()
val parsedTitle = title.value.trim()
val parsedDescription = description.value.trim()
account.signAndComputeBroadcast(
if (effectiveDTag != null) {
CalendarEvent.build(
title = parsedTitle,
content = parsedDescription,
dTag = effectiveDTag,
) {
if (selected.isNotEmpty()) aTags(selected.map { ATag(it) })
}
} else {
CalendarEvent.build(
title = parsedTitle,
content = parsedDescription,
) {
if (selected.isNotEmpty()) aTags(selected.map { ATag(it) })
}
},
)
return true
} finally {
isPublishing.value = false
}
}
private fun loadOwnedAppointments(): List<OwnedAppointmentSummary> {
val mePubKey = account.userProfile().pubkeyHex
val results =
LocalCache.notes
.filterIntoSet { _, note ->
val e = note.event
(e is CalendarTimeSlotEvent || e is CalendarDateSlotEvent) && e.pubKey == mePubKey
}.mapNotNull { note ->
when (val e = note.event) {
is CalendarTimeSlotEvent ->
OwnedAppointmentSummary(
address = e.address(),
title = e.title().orEmpty().ifBlank { "(untitled)" },
startSeconds = e.start(),
isAllDay = false,
)
is CalendarDateSlotEvent ->
OwnedAppointmentSummary(
address = e.address(),
title = e.title().orEmpty().ifBlank { "(untitled)" },
// Date-only events don't have an instant; null sorts last in the
// upcoming-first comparator below.
startSeconds = null,
isAllDay = true,
)
else -> null
}
}
// Upcoming events first (closest start), then date-only/past — same intent as the
// main feed's UpcomingFirst ordering, simplified for the picker context.
val now = TimeUtils.now()
return results.sortedWith(
compareBy(
{ if (it.startSeconds == null || it.startSeconds >= now) 0 else 1 },
{ it.startSeconds ?: Long.MAX_VALUE },
),
)
}
}
+3
View File
@@ -1922,6 +1922,9 @@
<string name="new_picture">New Picture</string>
<string name="new_calendar_event">New Calendar Event</string>
<string name="new_calendar_collection">New Calendar</string>
<string name="edit_calendar_collection">Edit Calendar</string>
<string name="calendar_collection_events_section">Events in this calendar (%1$d)</string>
<string name="calendar_collection_no_events_yet">You haven\'t created any calendar events yet.</string>
<string name="calendar_view_feed">Feed</string>
<string name="calendar_view_month">Month</string>