Merge pull request #1817 from vitorpamplona/claude/add-post-expiration-date-Nmedg
Add NIP-40 expiration date support to note creation
This commit is contained in:
+61
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
|
||||
+24
@@ -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
|
||||
}
|
||||
|
||||
@@ -1294,6 +1294,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user