diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b30c433c7..c3fed36fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -144,7 +144,7 @@ fun AppNavigation( composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } - composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, accountViewModel, nav) } + composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } composableFromEndArgs { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index d62bdc771..8c5e24612 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3f75e4e1a..9f06f4b15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 4b709f1eb..d3d01100b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 214684a90..27358e239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 796f5145a..f28e07565 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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(null) + var expirationDays by mutableStateOf(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() @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index eb7996ddb..29d5692d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 072328acc..f959c987a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index c3048c143..47595a0c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -110,6 +110,7 @@ fun MessagesTwoPane( draftMessage = it.message, replyToNote = it.replyId, editFromDraft = it.draftId, + expiresDays = it.expiresDays, nav = nav, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6b7c23159..a8ba70b2f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1249,4 +1249,5 @@ Crash Report found Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared Send it + This message will disappear in %1$d days diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index fa116d679..e069d9169 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -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, signer: NostrSigner, - ): List = - mapNotNullAsync( + ): List { + 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, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index 9d79f9d7c..a2def5540 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -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), ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 0608a06ef..01a72f03d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -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), ) }