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
This commit is contained in:
Claude
2026-03-12 10:38:23 +00:00
parent 548ce0a69d
commit e07d42e3da
5 changed files with 303 additions and 0 deletions
@@ -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),
)
}
}
}
@@ -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)
}
}
}
@@ -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
}
@@ -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<LocationState.LocationResult>? = 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
}
+8
View File
@@ -1291,6 +1291,14 @@
<string name="add_content_warning">Add content warning</string>
<string name="remove_content_warning">Remove content warning</string>
<string name="add_expiration_date">Add expiration date</string>
<string name="remove_expiration_date">Remove expiration date</string>
<string name="expiration_date_label">Expiration Date</string>
<string name="expiration_date_explainer">Post will be hidden by clients after this date (NIP-40)</string>
<string name="expiration_date_select">Select expiration date and time</string>
<string name="expiration_expires_in">Expires in %1$s</string>
<string name="expiration_time">Expiration Time</string>
<string name="show_npub_as_a_qr_code">Show npub as a QR code</string>
<string name="show_nprofile_as_a_qr_code">Show nprofile as a QR code</string>