Adds support for expirations in DMs

This commit is contained in:
Vitor Pamplona
2025-08-29 09:34:26 -04:00
parent ecf530d512
commit 458a7c7377
13 changed files with 106 additions and 13 deletions
@@ -144,7 +144,7 @@ fun AppNavigation(
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> {
@@ -138,6 +138,7 @@ fun routeToMessage(
draftMessage: String?,
replyId: HexKey? = null,
draftId: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
): Route =
routeToMessage(
@@ -145,6 +146,7 @@ fun routeToMessage(
draftMessage,
replyId,
draftId,
expiresDays,
accountViewModel,
)
@@ -153,12 +155,14 @@ fun routeToMessage(
draftMessage: String?,
replyId: HexKey? = null,
draftId: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
) = routeToMessage(
ChatroomKey(users),
draftMessage,
replyId,
draftId,
expiresDays,
accountViewModel,
)
@@ -167,19 +171,21 @@ fun routeToMessage(
draftMessage: String?,
replyId: HexKey? = null,
draftId: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
): Route = routeToMessage(room, draftMessage, replyId, draftId, accountViewModel.account)
): Route = routeToMessage(room, draftMessage, replyId, draftId, expiresDays, accountViewModel.account)
fun routeToMessage(
room: ChatroomKey,
draftMessage: String? = null,
replyId: HexKey? = null,
draftId: HexKey? = null,
expiresDays: Int? = null,
account: Account,
): Route {
account.chatroomList.getOrCreatePrivateChatroom(room)
return Route.Room(room, draftMessage, replyId, draftId)
return Route.Room(room, draftMessage, replyId, draftId, expiresDays)
}
fun routeToMessage(
@@ -187,8 +193,9 @@ fun routeToMessage(
draftMessage: String?,
replyId: HexKey? = null,
draftId: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, accountViewModel)
): Route = routeToMessage(user.pubkeyHex, draftMessage, replyId, draftId, expiresDays, accountViewModel)
fun routeFor(note: EphemeralChatChannel): Route = Route.EphemeralChat(note.roomId.id, note.roomId.relayUrl.url)
@@ -126,12 +126,14 @@ sealed class Route {
val message: String? = null,
val replyId: HexKey? = null,
val draftId: HexKey? = null,
val expiresDays: Int? = null,
) : Route() {
constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null) : this(
constructor(key: ChatroomKey, message: String? = null, replyId: HexKey? = null, draftId: HexKey? = null, expiresDays: Int? = null) : this(
id = key.users.joinToString(","),
message = message,
replyId = replyId,
draftId = draftId,
expiresDays = expiresDays,
)
fun toKey(): ChatroomKey = ChatroomKey(id.split(",").toSet())
@@ -41,6 +41,7 @@ fun ChatroomScreen(
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -57,6 +58,7 @@ fun ChatroomScreen(
draftMessage = draftMessage,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
expiresDays = expiresDays,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -52,6 +52,7 @@ fun ChatroomView(
draftMessage: String?,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
expiresDays: Int? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -79,7 +80,7 @@ fun ChatroomView(
}
}
if (editFromDraft != null) {
LaunchedEffect(replyToNote, newPostModel, accountViewModel) {
LaunchedEffect(editFromDraft, newPostModel, accountViewModel) {
accountViewModel.checkGetOrCreateNote(editFromDraft) {
if (it != null) {
newPostModel.editFromDraft(it)
@@ -87,6 +88,11 @@ fun ChatroomView(
}
}
}
if (expiresDays != null) {
LaunchedEffect(expiresDays, newPostModel, accountViewModel) {
newPostModel.loadExpiration(expiresDays)
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
@@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
@@ -88,6 +89,7 @@ import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -168,6 +170,8 @@ class ChatNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null)
var expirationDays by mutableStateOf<Int?>(null)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
@@ -267,6 +271,10 @@ class ChatNewMessageViewModel :
}
}
fun loadExpiration(expirationDays: Int) {
this.expirationDays = expirationDays
}
private fun loadFromDraft(draft: Note) {
val draftEvent = draft.event ?: return
@@ -341,6 +349,8 @@ class ChatNewMessageViewModel :
}
urlPreviews.update(message)
expirationDays = draftEvent.expiration()?.let { (it / 86_400).toInt() }
iMetaAttachments.addAll(draftEvent.imetas())
requiresNIP17 = draftEvent is NIP17Group
@@ -438,6 +448,8 @@ class ChatNewMessageViewModel :
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null
val expiration = expirationDays?.let { TimeUtils.now() + it.toLong() * 86_400 }
if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) {
val replyHint = replyTo.value?.toEventHint<BaseDMGroupEvent>()
@@ -452,6 +464,7 @@ class ChatNewMessageViewModel :
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
expiration?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
@@ -466,6 +479,7 @@ class ChatNewMessageViewModel :
localZapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
contentWarningReason?.let { contentWarning(it) }
expiration?.let { expiration(it) }
emojis(emojis)
imetas(usedAttachments)
@@ -487,7 +501,9 @@ class ChatNewMessageViewModel :
imetas = usedAttachments,
replyingTo = replyTo.value?.toEId(),
signer = accountViewModel.account.signer,
)
) {
expiration?.let { expiration(it) }
}
if (draftTag != null) {
accountViewModel.account.createAndSendDraftIgnoreErrors(draftTag, template)
@@ -518,6 +534,7 @@ class ChatNewMessageViewModel :
subject = TextFieldValue("")
replyTo.value = null
expirationDays = null
wantsInvoice = false
wantsZapraiser = false
@@ -194,7 +194,7 @@ fun NewGroupDMScreen(
accountViewModel.runIOCatching {
postViewModel.sendPostSync()
postViewModel.room?.let {
nav.nav(routeToMessage(it, null, null, null, accountViewModel))
nav.nav(routeToMessage(it, null, null, null, null, accountViewModel))
}
}
nav.popBack()
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -32,6 +33,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -52,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.PostKeyboard
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -127,6 +131,18 @@ fun PrivateMessageEditFieldRow(
)
}
channelScreenModel.expirationDays?.let {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.Center) {
Text(
stringResource(R.string.this_message_will_disappear_in_days, it),
fontSize = Font12SP,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
EditField(channelScreenModel, onSendNewMessage, accountViewModel)
}
}
@@ -110,6 +110,7 @@ fun MessagesTwoPane(
draftMessage = it.message,
replyToNote = it.replyId,
editFromDraft = it.draftId,
expiresDays = it.expiresDays,
nav = nav,
)
}
+1
View File
@@ -1249,4 +1249,5 @@
<string name="crashreport_found">Crash Report found</string>
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared</string>
<string name="crashreport_found_send">Send it</string>
<string name="this_message_will_disappear_in_days">This message will disappear in %1$d days</string>
</resources>
@@ -29,9 +29,11 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import java.time.temporal.TemporalAdjusters.next
class NIP17Factory {
data class Result(
@@ -43,8 +45,17 @@ class NIP17Factory {
event: Event,
to: Set<HexKey>,
signer: NostrSigner,
): List<GiftWrapEvent> =
mapNotNullAsync(
): List<GiftWrapEvent> {
val innerExpDelta =
event.expiration()?.let {
if (it > event.createdAt) {
it - event.createdAt
} else {
null
}
}
return mapNotNullAsync(
to.toList(),
) { next ->
GiftWrapEvent.create(
@@ -52,11 +63,14 @@ class NIP17Factory {
SealedRumorEvent.create(
event = event,
encryptTo = next,
expirationDelta = innerExpDelta,
signer = signer,
),
recipientPubKey = next,
expirationDelta = innerExpDelta,
)
}
}
suspend fun createMessageNIP17(
template: EventTemplate<ChatMessageEvent>,
@@ -25,6 +25,7 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip59Giftwrap.HostStub
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
@@ -99,23 +100,33 @@ class SealedRumorEvent(
event: Event,
encryptTo: HexKey,
signer: NostrSigner,
expirationDelta: Long? = null,
createdAt: Long = TimeUtils.now(),
): SealedRumorEvent {
val rumor = Rumor.create(event)
return create(rumor, encryptTo, signer, createdAt)
return create(rumor, encryptTo, signer, expirationDelta, createdAt)
}
suspend fun create(
rumor: Rumor,
encryptTo: HexKey,
signer: NostrSigner,
expirationDelta: Long? = null,
createdAt: Long = TimeUtils.randomWithTwoDays(),
): SealedRumorEvent {
val msg = Rumor.toJson(rumor)
val tags =
expirationDelta?.let {
// minimum expiration is two days in the future due to the random created at
// this will make sure the even arrives and is not deleted because of the 2 days.
arrayOf(ExpirationTag.assemble(createdAt + it + TimeUtils.twoDays()))
} ?: emptyArray()
return signer.sign(
createdAt = createdAt,
kind = KIND,
tags = emptyArray(),
tags = tags,
content = signer.nip44Encrypt(msg, encryptTo),
)
}
@@ -28,7 +28,9 @@ import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.nip59Giftwrap.HostStub
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -103,13 +105,27 @@ class GiftWrapEvent(
suspend fun create(
event: Event,
recipientPubKey: HexKey,
expirationDelta: Long? = null,
createdAt: Long = TimeUtils.randomWithTwoDays(),
): GiftWrapEvent {
val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key
val tags =
expirationDelta?.let {
// minimum expiration is two days in the future due to the random created at
// this will make sure the even arrives and is not deleted because of the 2 days.
arrayOf(
PTag.assemble(recipientPubKey, null),
ExpirationTag.assemble(createdAt + it + TimeUtils.twoDays()),
)
} ?: arrayOf(
PTag.assemble(recipientPubKey, null),
)
return signer.sign(
createdAt = createdAt,
kind = KIND,
tags = arrayOf(arrayOf("p", recipientPubKey)),
tags = tags,
content = signer.nip44Encrypt(event.toJson(), recipientPubKey),
)
}