From e07d42e3dad525278def87ca270c97ebc25af417 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 10:38:23 +0000 Subject: [PATCH] feat: add NIP-40 expiration date to short note post screen Allows users to add an expiration date to posts via NIP-40. The feature follows the same pattern as content warning (toggle button in the bottom action bar, expandable section in the post body) and uses the same calendar/time picker UI as poll deadlines. - ExpirationDateButton: timer icon toggles orange when active - ExpirationDatePicker: date+time picker with "Expires in X" display - ViewModel: wantsExpirationDate + expirationDate state, wired into both TextNoteEvent and PollEvent builders, loaded from drafts - Strings: add_expiration_date, expiration_date_label, etc. https://claude.ai/code/session_016QjmArr6zfM1Qx58iNrntA --- .../expiration/ExpirationDateButton.kt | 61 ++++++ .../expiration/ExpirationDatePicker.kt | 195 ++++++++++++++++++ .../loggedIn/home/ShortNotePostScreen.kt | 15 ++ .../loggedIn/home/ShortNotePostViewModel.kt | 24 +++ amethyst/src/main/res/values/strings.xml | 8 + 5 files changed, 303 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDateButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDateButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDateButton.kt new file mode 100644 index 000000000..487662291 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDateButton.kt @@ -0,0 +1,61 @@ +/* + * 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.note.creators.expiration + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.outlined.TimerOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun ExpirationDateButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isActive) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = stringRes(R.string.add_expiration_date), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Outlined.TimerOff, + contentDescription = stringRes(R.string.remove_expiration_date), + modifier = Modifier.size(20.dp), + tint = Color(0xFFFF6600), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt new file mode 100644 index 000000000..5dcd18b0a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/expiration/ExpirationDatePicker.kt @@ -0,0 +1,195 @@ +/* + * 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.note.creators.expiration + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExpirationDatePicker(model: ShortNotePostViewModel) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + val currentTime = Instant.ofEpochMilli(model.expirationDate * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = model.expirationDate * 1000, + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + return utcTimeMillis >= System.currentTimeMillis() - 86400000 + } + }, + ) + + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, + ) + + val context = LocalContext.current + + Column(Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + ) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = stringRes(R.string.expiration_date_label), + modifier = Modifier.size(20.dp), + tint = Color(0xFFFF6600), + ) + + Text( + text = stringRes(R.string.expiration_date_label), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + } + + HorizontalDivider(thickness = DividerThickness) + + Text( + text = stringRes(R.string.expiration_date_explainer), + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(vertical = 10.dp), + ) + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Timer, contentDescription = stringResource(R.string.expiration_date_select)) + Spacer(Modifier.width(12.dp)) + + if (model.expirationDate < TimeUtils.oneMinuteFromNow()) { + Text(stringRes(R.string.expiration_date_label) + " " + model.expirationDate, style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = stringRes(R.string.expiration_expires_in, timeAheadNoDot(model.expirationDate, context)), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + } + + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringResource(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + if (showTimePicker) { + TimePickerDialog( + title = { + Text(stringResource(R.string.expiration_time)) + }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.oneDayAhead() + + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + + model.expirationDate = datetimeLocalTimeZone - offset.totalSeconds + + showTimePicker = false + }, + ) { Text(stringResource(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 99f8a6e98..6432d2169 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -78,6 +78,8 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton +import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton +import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton @@ -311,6 +313,15 @@ private fun NewPostScreenBody( } } + if (postViewModel.wantsExpirationDate) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ExpirationDatePicker(postViewModel) + } + } + if (postViewModel.wantsToAddGeoHash) { Row( verticalAlignment = CenterVertically, @@ -539,6 +550,10 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { postViewModel.toggleMarkAsSensitive() } + ExpirationDateButton(postViewModel.wantsExpirationDate) { + postViewModel.toggleExpirationDate() + } + AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 5eb51fd1a..e07e0141d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW +import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress @@ -239,6 +240,10 @@ open class ShortNotePostViewModel : var wantsToMarkAsSensitive by mutableStateOf(false) var contentWarningDescription by mutableStateOf("") + // Expiration Date (NIP-40) + var wantsExpirationDate by mutableStateOf(false) + var expirationDate by mutableLongStateOf(TimeUtils.oneDayAhead()) + // GeoHash var wantsToAddGeoHash by mutableStateOf(false) var location: StateFlow? = null @@ -427,6 +432,10 @@ open class ShortNotePostViewModel : wantsToMarkAsSensitive = draftEvent.isSensitive() contentWarningDescription = draftEvent.contentWarningReason() ?: "" + val draftExpiration = draftEvent.tags.expiration() + wantsExpirationDate = draftExpiration != null + expirationDate = draftExpiration ?: TimeUtils.oneDayAhead() + val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null if (geohash != null) { @@ -507,6 +516,10 @@ open class ShortNotePostViewModel : wantsToMarkAsSensitive = draftEvent.isSensitive() contentWarningDescription = draftEvent.contentWarningReason() ?: "" + val draftExpiration = draftEvent.tags.expiration() + wantsExpirationDate = draftExpiration != null + expirationDate = draftExpiration ?: TimeUtils.oneDayAhead() + val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null if (geohash != null) { @@ -690,6 +703,7 @@ open class ShortNotePostViewModel : val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null + val localExpirationDate = if (wantsExpirationDate) expirationDate else null return if (wantsPoll) { val options = pollOptions.map { it.value } @@ -710,6 +724,7 @@ open class ShortNotePostViewModel : localZapRaiserAmount?.let { zapraiser(it) } zapReceiver?.let { zapSplits(it) } contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } emojis(emojis) imetas(usedAttachments) @@ -765,6 +780,7 @@ open class ShortNotePostViewModel : localZapRaiserAmount?.let { zapraiser(it) } zapReceiver?.let { zapSplits(it) } contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } emojis(emojis) imetas(usedAttachments) @@ -1237,5 +1253,13 @@ open class ShortNotePostViewModel : draftTag.newVersion() } + fun toggleExpirationDate() { + wantsExpirationDate = !wantsExpirationDate + if (wantsExpirationDate) { + expirationDate = TimeUtils.oneDayAhead() + } + draftTag.newVersion() + } + override fun locationManager(): LocationState = Amethyst.instance.locationManager } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 799135f7d..69eef425e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1291,6 +1291,14 @@ Add content warning Remove content warning + + Add expiration date + Remove expiration date + Expiration Date + Post will be hidden by clients after this date (NIP-40) + Select expiration date and time + Expires in %1$s + Expiration Time Show npub as a QR code Show nprofile as a QR code