diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c097d8b9a..da8ba2f09 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.Contact import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl @@ -845,7 +846,14 @@ class Account( } suspend fun delete(note: Note) { - return delete(listOf(note)) + if (note.isDraft()) { + note.event?.let { + val drafts = LocalCache.getDrafts(it.id()) + return delete(drafts) + } + } else { + return delete(listOf(note)) + } } suspend fun delete(notes: List) { @@ -897,10 +905,17 @@ class Account( fun broadcast(note: Note) { note.event?.let { - if (it is WrappedEvent && it.host != null) { - it.host?.let { hostEvent -> Client.send(hostEvent) } + if (note.isDraft()) { + val drafts = LocalCache.getDrafts(it.id()) + drafts.forEach { draftNote -> + broadcast(draftNote) + } } else { - Client.send(it) + if (it is WrappedEvent && it.host != null) { + it.host?.let { hostEvent -> Client.send(hostEvent) } + } else { + Client.send(it) + } } } } @@ -929,6 +944,7 @@ class Account( fun timestamp(note: Note) { if (!isWriteable()) return + if (note.isDraft()) return val id = note.event?.id() ?: note.idHex @@ -1318,6 +1334,7 @@ class Account( relayList: List? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1345,14 +1362,24 @@ class Account( geohash = geohash, nip94attachments = nip94attachments, signer = signer, + isDraft = draftTag != null, ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } } } } @@ -1373,6 +1400,7 @@ class Account( relayList: List? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1396,20 +1424,30 @@ class Account( nip94attachments = nip94attachments, forkedFrom = forkedFrom, signer = signer, + isDraft = draftTag != null, ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) } - } - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } } } } @@ -1430,6 +1468,7 @@ class Account( relayList: List? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1453,20 +1492,30 @@ class Account( nip94attachments = nip94attachments, forkedFrom = forkedFrom, signer = signer, + isDraft = draftTag != null, ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) - - // broadcast replied notes - replyingTo?.let { - LocalCache.getNoteIfExists(replyingTo)?.event?.let { - Client.send(it, relayList = relayList) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) } - } - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + + // broadcast replied notes + replyingTo?.let { + LocalCache.getNoteIfExists(replyingTo)?.event?.let { + Client.send(it, relayList = relayList) + } + } + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } } } } @@ -1510,6 +1559,7 @@ class Account( relayList: List? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1533,15 +1583,25 @@ class Account( zapRaiserAmount = zapRaiserAmount, geohash = geohash, nip94attachments = nip94attachments, + isDraft = draftTag != null, ) { - Client.send(it, relayList = relayList) - LocalCache.justConsume(it, null) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) + } + } else { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) - // Rebroadcast replies and tags to the current relay set - replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } - addresses?.forEach { - LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { - Client.send(it, relayList = relayList) + // Rebroadcast replies and tags to the current relay set + replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } } + addresses?.forEach { + LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let { + Client.send(it, relayList = relayList) + } } } } @@ -1557,6 +1617,7 @@ class Account( zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1574,9 +1635,19 @@ class Account( geohash = geohash, nip94attachments = nip94attachments, signer = signer, + isDraft = draftTag != null, ) { - Client.send(it) - LocalCache.justConsume(it, null) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) + } + } else { + Client.send(it) + LocalCache.justConsume(it, null) + } } } @@ -1590,6 +1661,7 @@ class Account( zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1608,9 +1680,19 @@ class Account( geohash = geohash, nip94attachments = nip94attachments, signer = signer, + isDraft = draftTag != null, ) { - Client.send(it) - LocalCache.justConsume(it, null) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) + } + } else { + Client.send(it) + LocalCache.justConsume(it, null) + } } } @@ -1624,6 +1706,7 @@ class Account( zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { sendPrivateMessage( message, @@ -1635,6 +1718,7 @@ class Account( zapRaiserAmount, geohash, nip94attachments, + draftTag, ) } @@ -1648,6 +1732,7 @@ class Account( zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String?, ) { if (!isWriteable()) return @@ -1667,9 +1752,19 @@ class Account( nip94attachments = nip94attachments, signer = signer, advertiseNip18 = false, + isDraft = draftTag != null, ) { - Client.send(it) - LocalCache.consume(it, null) + if (draftTag != null) { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.id()) + } + } else { + Client.send(it) + LocalCache.consume(it, null) + } } } @@ -1684,6 +1779,7 @@ class Account( zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String? = null, ) { if (!isWriteable()) return @@ -1701,9 +1797,19 @@ class Account( zapRaiserAmount = zapRaiserAmount, geohash = geohash, nip94attachments = nip94attachments, + draftTag = draftTag, signer = signer, ) { - broadcastPrivately(it) + if (draftTag != null) { + DraftEvent.create(draftTag, it.msg, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + LocalCache.justConsume(it.msg, null) + LocalCache.addDraft(draftTag, draftEvent.id(), it.msg.id()) + } + } else { + broadcastPrivately(it) + } } } @@ -1851,6 +1957,7 @@ class Account( isPrivate: Boolean, ) { if (!isWriteable()) return + if (note.isDraft()) return if (note is AddressableNote) { BookmarkListEvent.addReplaceable( @@ -2218,6 +2325,7 @@ class Account( fun cachedDecryptContent(note: Note): String? { val event = note.event + return if (event is PrivateDmEvent && isWriteable()) { event.cachedContentFor(signer) } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt new file mode 100644 index 000000000..9dc3b1c44 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2024 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.model + +data class Drafts(val mainId: String, val eventId: String) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 874593c96..21f48c2c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.events.CommunityListEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.ContactListEvent import com.vitorpamplona.quartz.events.DeletionEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.Event @@ -128,7 +129,7 @@ object LocalCache { val users = LargeCache() val notes = LargeCache() val addressables = LargeCache() - + val drafts = ConcurrentHashMap>() val channels = LargeCache() val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) @@ -141,6 +142,34 @@ object LocalCache { return null } + fun draftNotes(draftTag: String): List { + return drafts[draftTag]?.mapNotNull { + getNoteIfExists(it.mainId) + } ?: listOf() + } + + fun getDrafts(eventId: String): List { + return drafts.filter { + it.value.any { it.eventId == eventId } + }.values.map { + it.mapNotNull { + checkGetOrCreateNote(it.mainId) + } + }.flatten() + } + + fun addDraft( + key: String, + mainId: String, + draftId: String, + ) { + val data = drafts[key] ?: mutableListOf() + if (data.none { it.mainId == mainId }) { + data.add(Drafts(mainId, draftId)) + drafts[key] = data + } + } + fun getOrCreateUser(key: HexKey): User { // checkNotInMainThread() require(isValidHex(key = key)) { "$key is not a valid hex" } @@ -2013,6 +2042,13 @@ object LocalCache { } } + private fun consume( + event: DraftEvent, + relay: Relay?, + ) { + consumeBaseReplaceable(event, relay) + } + fun justConsume( event: Event, relay: Relay?, @@ -2050,6 +2086,7 @@ object LocalCache { } is ContactListEvent -> consume(event) is DeletionEvent -> consume(event) + is DraftEvent -> consume(event, relay) is EmojiPackEvent -> consume(event, relay) is EmojiPackSelectionEvent -> consume(event, relay) is SealedGossipEvent -> consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 1da77c850..40189567e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -184,6 +184,13 @@ open class Note(val idHex: String) { open fun createdAt() = event?.createdAt() + fun isDraft(): Boolean { + event?.let { + return it.sig().isBlank() + } + return false + } + fun loadEvent( event: Event, author: User, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 0e49e30bf..34a51b4c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ContactListEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface @@ -229,6 +230,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { ) } + fun createDraftsFilter() = + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = listOf(DraftEvent.KIND), + authors = listOf(account.userProfile().pubkeyHex), + ), + ) + fun createGiftWrapsToMeFilter() = TypedFilter( types = COMMON_FEED_TYPES, @@ -262,22 +273,46 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { checkNotInMainThread() if (LocalCache.justVerify(event)) { - if (event is GiftWrapEvent) { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note != null && relay.brief in note.relays) return + when (event) { + is DraftEvent -> { + // Avoid decrypting over and over again if the event already exist. - event.cachedGift(account.signer) { this.consume(it, relay) } - } + val note = LocalCache.getNoteIfExists(event.id) + if (note != null && relay.brief in note.relays) return - if (event is SealedGossipEvent) { - // Avoid decrypting over and over again if the event already exist. - val note = LocalCache.getNoteIfExists(event.id) - if (note != null && relay.brief in note.relays) return + LocalCache.justConsume(event, relay) + event.plainContent(account.signer) { + val tag = + event.tags().filter { it.size > 1 && it[0] == "d" }.map { + it[1] + }.firstOrNull() - event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) } - } else { - LocalCache.justConsume(event, relay) + LocalCache.justConsume(it, relay) + tag?.let { lTag -> + LocalCache.addDraft(lTag, event.id(), it.id()) + } + } + } + + is GiftWrapEvent -> { + // Avoid decrypting over and over again if the event already exist. + val note = LocalCache.getNoteIfExists(event.id) + if (note != null && relay.brief in note.relays) return + + event.cachedGift(account.signer) { this.consume(it, relay) } + } + + is SealedGossipEvent -> { + // Avoid decrypting over and over again if the event already exist. + val note = LocalCache.getNoteIfExists(event.id) + if (note != null && relay.brief in note.relays) return + + event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) } + } + + else -> { + LocalCache.justConsume(event, relay) + } } } } @@ -328,6 +363,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountSettingsFilter(), createAccountLastPostsListFilter(), createOtherAccountsBaseFilter(), + createDraftsFilter(), ) .ifEmpty { null } } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt index 6f8378cc0..794fa5cbf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -34,8 +34,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun NewPollOption( @@ -45,7 +48,12 @@ fun NewPollOption( Row { val deleteIcon: @Composable (() -> Unit) = { IconButton( - onClick = { pollViewModel.pollOptions.remove(optionIndex) }, + onClick = { + pollViewModel.pollOptions.remove(optionIndex) + pollViewModel.viewModelScope.launch(Dispatchers.IO) { + pollViewModel.saveDraft() + } + }, ) { Icon( imageVector = Icons.Default.Delete, @@ -57,7 +65,12 @@ fun NewPollOption( OutlinedTextField( modifier = Modifier.weight(1F), value = pollViewModel.pollOptions[optionIndex] ?: "", - onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, + onValueChange = { + pollViewModel.pollOptions[optionIndex] = it + pollViewModel.viewModelScope.launch(Dispatchers.IO) { + pollViewModel.saveDraft() + } + }, label = { Text( text = stringResource(R.string.poll_option_index).format(optionIndex + 1), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index f7fe6ef4d..ea68886ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -119,6 +119,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -171,13 +172,18 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.lang.Math.round -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class) @Composable fun NewPostView( onClose: () -> Unit, @@ -185,6 +191,7 @@ fun NewPostView( quote: Note? = null, fork: Note? = null, version: Note? = null, + draft: Note? = null, enableMessageInterface: Boolean = false, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -200,9 +207,17 @@ fun NewPostView( var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } LaunchedEffect(Unit) { - postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version) - launch(Dispatchers.IO) { + postViewModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + postViewModel.sendPost(relayList = relayList, localDraft = postViewModel.draftTag) + } + } + launch(Dispatchers.IO) { + postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft) + postViewModel.imageUploadingError.collect { error -> withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } } @@ -582,6 +597,9 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { MarkAsSensitive(postViewModel) { postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } } AddGeoHash(postViewModel) { @@ -827,7 +845,12 @@ fun SellProduct(postViewModel: NewPostViewModel) { MyTextField( value = postViewModel.title, - onValueChange = { postViewModel.title = it }, + onValueChange = { + postViewModel.title = it + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } + }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( @@ -870,6 +893,9 @@ fun SellProduct(postViewModel: NewPostViewModel) { postViewModel.price = it } } + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } }, placeholder = { Text( @@ -934,7 +960,12 @@ fun SellProduct(postViewModel: NewPostViewModel) { TextSpinner( placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, options = conditionOptions, - onSelect = { postViewModel.condition = conditionTypes[it].first }, + onSelect = { + postViewModel.condition = conditionTypes[it].first + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } + }, modifier = Modifier .weight(1f) @@ -998,7 +1029,12 @@ fun SellProduct(postViewModel: NewPostViewModel) { categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second ?: "", options = categoryOptions, - onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) }, + onSelect = { + postViewModel.category = TextFieldValue(categoryTypes[it].second) + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } + }, modifier = Modifier .weight(1f) @@ -1033,7 +1069,12 @@ fun SellProduct(postViewModel: NewPostViewModel) { MyTextField( value = postViewModel.locationText, - onValueChange = { postViewModel.locationText = it }, + onValueChange = { + postViewModel.locationText = it + postViewModel.viewModelScope.launch(Dispatchers.IO) { + postViewModel.saveDraft() + } + }, modifier = Modifier.fillMaxWidth(), placeholder = { Text( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index de3e8e7b9..e2abf4c81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -69,10 +69,12 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import java.util.UUID enum class UserSuggestionAnchor { MAIN_MESSAGE, @@ -82,6 +84,7 @@ enum class UserSuggestionAnchor { @Stable open class NewPostViewModel() : ViewModel() { + var draftTag: String = UUID.randomUUID().toString() var accountViewModel: AccountViewModel? = null var account: Account? = null var requiresNIP24: Boolean = false @@ -164,6 +167,8 @@ open class NewPostViewModel() : ViewModel() { // NIP24 Wrapped DMs / Group messages var nip24 by mutableStateOf(false) + val draftTextChanges = Channel(Channel.CONFLATED) + fun lnAddress(): String? { return account?.userProfile()?.info?.lnAddress() } @@ -182,127 +187,236 @@ open class NewPostViewModel() : ViewModel() { quote: Note?, fork: Note?, version: Note?, + draft: Note?, ) { this.accountViewModel = accountViewModel this.account = accountViewModel.account - originalNote = replyingTo - replyingTo?.let { replyNote -> - if (replyNote.event is BaseTextNoteEvent) { - this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) - } else { - this.eTags = listOf(replyNote) - } + if (draft != null) { + loadFromDraft(draft, accountViewModel) + } else { + originalNote = replyingTo + replyingTo?.let { replyNote -> + if (replyNote.event is BaseTextNoteEvent) { + this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) + } else { + this.eTags = listOf(replyNote) + } - if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = - (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } - ?: emptyList() + if (replyNote.event !is CommunityDefinitionEvent) { + replyNote.author?.let { replyUser -> + val currentMentions = + (replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) } + ?: emptyList() - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) + if (currentMentions.contains(replyUser)) { + this.pTags = currentMentions + } else { + this.pTags = currentMentions.plus(replyUser) + } } } } - } - ?: run { - eTags = null - pTags = null + ?: run { + eTags = null + pTags = null + } + + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null + canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + contentToAddUrl = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + wantsToAddGeoHash = false + wantsZapraiser = false + zapRaiserAmount = null + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + + quote?.let { + message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() + + it.author?.let { quotedUser -> + if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { + forwardZapTo.addItem(quotedUser) + } + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.9f) + } + } } + fork?.let { + message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "") + urlPreview = findUrlInMessage() + + it.event?.isSensitive()?.let { + if (it) wantsToMarkAsSensitive = true + } + + it.event?.zapraiserAmount()?.let { + zapRaiserAmount = it + } + + it.event?.zapSplitSetup()?.let { + val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight } + + it.forEach { + if (!it.isLnAddress) { + forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat()) + } + } + } + + // Only adds if it is not already set up. + if (forwardZapTo.items.isEmpty()) { + it.author?.let { forkedAuthor -> + if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor) + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile()) + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.8f) + } + } + } + + it.author?.let { + if (this.pTags == null) { + this.pTags = listOf(it) + } else if (this.pTags?.contains(it) != true) { + this.pTags = listOf(it) + (this.pTags ?: emptyList()) + } + } + + forkedFromNote = it + } ?: run { + forkedFromNote = null + } + + if (!forwardZapTo.items.isEmpty()) { + wantsForwardZapTo = true + } + } + } + + private fun loadFromDraft( + draft: Note, + accountViewModel: AccountViewModel, + ) { + Log.d("draft", draft.event!!.toJson()) + + draftTag = LocalCache.drafts.filter { + it.value.any { it.eventId == draft.event?.id() } + }.keys.firstOrNull() ?: draftTag + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null - canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null contentToAddUrl = null - wantsForwardZapTo = false - wantsToMarkAsSensitive = false - wantsToAddGeoHash = false - wantsZapraiser = false - zapRaiserAmount = null + val localfowardZapTo = draft.event?.tags()?.filter { it.size > 1 && it[0] == "zap" } ?: listOf() forwardZapTo = Split() + localfowardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.addItem(user, value) + } forwardZapToEditting = TextFieldValue("") + wantsForwardZapTo = localfowardZapTo.isNotEmpty() - quote?.let { - message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}") - urlPreview = findUrlInMessage() - - it.author?.let { quotedUser -> - if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { - if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) { - forwardZapTo.addItem(quotedUser) - } - if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { - forwardZapTo.addItem(accountViewModel.userProfile()) - } - - val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex } - forwardZapTo.updatePercentage(pos, 0.9f) - } - } + wantsToMarkAsSensitive = draft.event?.tags()?.any { it.size > 1 && it[0] == "content-warning" } ?: false + wantsToAddGeoHash = draft.event?.tags()?.any { it.size > 1 && it[0] == "g" } ?: false + val zapraiser = draft.event?.tags()?.filter { it.size > 1 && it[0] == "zapraiser" } ?: listOf() + wantsZapraiser = zapraiser.isNotEmpty() + zapRaiserAmount = null + if (wantsZapraiser) { + zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0 } - fork?.let { - message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "") - urlPreview = findUrlInMessage() - - it.event?.isSensitive()?.let { - if (it) wantsToMarkAsSensitive = true + eTags = + draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }?.mapNotNull { + val note = LocalCache.checkGetOrCreateNote(it[1]) + note } - it.event?.zapraiserAmount()?.let { - zapRaiserAmount = it + pTags = + draft.event?.tags()?.filter { it.size > 1 && it[0] == "p" }?.map { + LocalCache.getOrCreateUser(it[1]) } - it.event?.zapSplitSetup()?.let { - val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight } - - it.forEach { - if (!it.isLnAddress) { - forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat()) - } - } - } - - // Only adds if it is not already set up. - if (forwardZapTo.items.isEmpty()) { - it.author?.let { forkedAuthor -> - if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { - if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor) - if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile()) - - val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex } - forwardZapTo.updatePercentage(pos, 0.8f) - } - } - } - - it.author?.let { - if (this.pTags == null) { - this.pTags = listOf(it) - } else if (this.pTags?.contains(it) != true) { - this.pTags = listOf(it) + (this.pTags ?: emptyList()) - } - } - - forkedFromNote = it - } ?: run { - forkedFromNote = null + draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "fork" }?.forEach { + val note = LocalCache.checkGetOrCreateNote(it[1]) + forkedFromNote = note } - if (!forwardZapTo.items.isEmpty()) { + originalNote = + draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }?.map { + LocalCache.checkGetOrCreateNote(it[1]) + }?.firstOrNull() + + canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null + + if (forwardZapTo.items.isNotEmpty()) { wantsForwardZapTo = true } + + val polls = draft.event?.tags()?.filter { it.size > 1 && it[0] == "poll_option" } ?: emptyList() + wantsPoll = polls.isNotEmpty() + + polls.forEach { + pollOptions[it[1].toInt()] = it[2] + } + + val minMax = draft.event?.tags()?.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } ?: listOf() + minMax.forEach { + if (it[0] == "value_maximum") { + valueMaximum = it[1].toInt() + } else if (it[0] == "value_minimum") { + valueMinimum = it[1].toInt() + } + } + + wantsProduct = draft.event?.kind() == 30402 + + title = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "title" }?.map { it[1] }?.firstOrNull() ?: "") + price = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "price" }?.map { it[1] }?.firstOrNull() ?: "") + category = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }?.firstOrNull() ?: "") + locationText = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "location" }?.map { it[1] }?.firstOrNull() ?: "") + condition = ClassifiedsEvent.CONDITION.entries.firstOrNull { + it.value == draft.event?.tags()?.filter { it.size > 1 && it[0] == "condition" }?.map { it[1] }?.firstOrNull() + } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW + + message = + if (draft.event is PrivateDmEvent) { + val event = draft.event as PrivateDmEvent + TextFieldValue(event.cachedContentFor(accountViewModel.account.signer) ?: "") + } else { + TextFieldValue(draft.event?.content() ?: "") + } + + nip24 = draft.event is ChatMessageEvent + urlPreview = findUrlInMessage() } - fun sendPost(relayList: List? = null) { - viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + fun sendPost( + relayList: List? = null, + localDraft: String? = null, + ) { + viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList, localDraft) } } - suspend fun innerSendPost(relayList: List? = null) { + private suspend fun innerSendPost( + relayList: List? = null, + localDraft: String?, + ) { if (accountViewModel == null) { cancel() return @@ -363,6 +477,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else { account?.sendChannelMessage( @@ -375,6 +490,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } } else if (originalNote?.event is PrivateDmEvent) { @@ -388,6 +504,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else if (originalNote?.event is ChatMessageEvent) { val receivers = @@ -423,6 +540,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else { account?.sendPrivateMessage( @@ -435,6 +553,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } } else if (originalNote?.event is GitIssueEvent) { @@ -475,6 +594,7 @@ open class NewPostViewModel() : ViewModel() { relayList = relayList, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else { if (wantsPoll) { @@ -493,6 +613,7 @@ open class NewPostViewModel() : ViewModel() { relayList, geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else if (wantsProduct) { account?.sendClassifieds( @@ -511,6 +632,7 @@ open class NewPostViewModel() : ViewModel() { relayList = relayList, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else { // adds markers @@ -547,11 +669,13 @@ open class NewPostViewModel() : ViewModel() { relayList = relayList, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } } - - cancel() + if (localDraft == null) { + cancel() + } } fun upload( @@ -635,6 +759,7 @@ open class NewPostViewModel() : ViewModel() { urlPreview = null isUploadingImage = false pTags = null + eTags = null wantsDirectMessage = false @@ -663,6 +788,11 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() userSuggestionAnchor = null userSuggestionsMainMessage = null + originalNote = null + + viewModelScope.launch(Dispatchers.IO) { + accountViewModel?.deleteDraft(draftTag) + } NostrSearchEventOrUserDataSource.clear() } @@ -679,6 +809,10 @@ open class NewPostViewModel() : ViewModel() { pTags = pTags?.filter { it != userToRemove } } + open suspend fun saveDraft() { + draftTextChanges.send("") + } + open fun updateMessage(it: TextFieldValue) { message = it urlPreview = findUrlInMessage() @@ -701,6 +835,10 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } } + + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } open fun updateToUsers(it: TextFieldValue) { @@ -724,10 +862,16 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } } + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } open fun updateSubject(it: TextFieldValue) { subject = it + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } open fun updateZapForwardTo(it: TextFieldValue) { @@ -754,6 +898,9 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } } + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } open fun autocompleteWithUser(item: User) { @@ -799,6 +946,10 @@ open class NewPostViewModel() : ViewModel() { userSuggestionsMainMessage = null userSuggestions = emptyList() } + + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } private fun newStateMapPollOptions(): SnapshotStateMap { @@ -869,6 +1020,9 @@ open class NewPostViewModel() : ViewModel() { message = message.insertUrlAtCursor(imageUrl) urlPreview = findUrlInMessage() + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } }, onError = { @@ -913,6 +1067,9 @@ open class NewPostViewModel() : ViewModel() { } urlPreview = findUrlInMessage() + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } }, onError = { @@ -933,6 +1090,7 @@ open class NewPostViewModel() : ViewModel() { locUtil?.let { location = it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() } + viewModelScope.launch(Dispatchers.IO) { saveDraft() } } viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } } @@ -957,6 +1115,11 @@ open class NewPostViewModel() : ViewModel() { } else { nip24 = !nip24 } + if (message.text.isNotBlank()) { + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } + } } fun updateMinZapAmountForPoll(textMin: String) { @@ -976,6 +1139,9 @@ open class NewPostViewModel() : ViewModel() { } checkMinMax() + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } fun updateMaxZapAmountForPoll(textMax: String) { @@ -995,6 +1161,9 @@ open class NewPostViewModel() : ViewModel() { } checkMinMax() + viewModelScope.launch(Dispatchers.IO) { + saveDraft() + } } fun checkMinMax() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 67ae8d44c..a2715ec99 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.Lightning @@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ZapRaiserRequest( @@ -98,6 +101,9 @@ fun ZapRaiserRequest( } else { newPostViewModel.zapRaiserAmount = it.toLongOrNull() } + newPostViewModel.viewModelScope.launch(Dispatchers.IO) { + newPostViewModel.saveDraft() + } } }, placeholder = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index eb39e7313..7a3496f75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus +import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewRelayListView import com.vitorpamplona.amethyst.ui.components.ClickableText import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -159,7 +160,10 @@ fun DrawerContent( ) ListContent( - modifier = Modifier.fillMaxWidth().weight(1f), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), drawerState, openSheet, accountViewModel, @@ -231,7 +235,8 @@ fun ProfileContentTemplate( model = profilePicture, contentDescription = stringResource(id = R.string.profile_image), modifier = - Modifier.width(100.dp) + Modifier + .width(100.dp) .height(100.dp) .clip(shape = CircleShape) .border(3.dp, MaterialTheme.colorScheme.background, CircleShape) @@ -244,7 +249,10 @@ fun ProfileContentTemplate( CreateTextWithEmoji( text = bestDisplayName, tags = tags, - modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick), + modifier = + Modifier + .padding(top = 7.dp) + .clickable(onClick = onClick), fontWeight = FontWeight.Bold, fontSize = 18.sp, maxLines = 1, @@ -454,8 +462,17 @@ fun ListContent( val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) } val context = LocalContext.current + var draftText by remember { + mutableStateOf(null) + } + + var wantsToPost by remember { mutableStateOf(false) } + Column( - modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()), + modifier = + modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), ) { NavigationRow( title = stringResource(R.string.profile), @@ -571,6 +588,18 @@ fun ListContent( ) } + if (wantsToPost) { + NewPostView( + { + wantsToPost = false + draftText = null + coroutineScope.launch { drawerState.close() } + }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + if (disconnectTorDialog) { AlertDialog( title = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) }, @@ -662,7 +691,8 @@ fun IconRow( ) { Row( modifier = - Modifier.fillMaxWidth() + Modifier + .fillMaxWidth() .combinedClickable( onClick = onClick, onLongClick = onLongClick, @@ -693,10 +723,16 @@ fun IconRowRelays( onClick: () -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth().clickable { onClick() }, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() }, ) { Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 15.dp, horizontal = 25.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -737,7 +773,10 @@ fun BottomContent( thickness = DividerThickness, ) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 15.dp), verticalAlignment = Alignment.CenterVertically, ) { ClickableText( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 85f8ab9ca..7efedde72 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -147,7 +147,7 @@ fun NormalChannelCard( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { showPopup -> CheckNewAndRenderChannelCard( baseNote, routeForLastRead, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 7a3685f4d..9a195f920 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.SensitivityWarning @@ -102,6 +103,7 @@ fun ChatroomMessageCompose( innerQuote: Boolean = false, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -120,6 +122,7 @@ fun ChatroomMessageCompose( canPreview, parentBackgroundColor, accountViewModel, + newPostViewModel, nav, onWantsToReply, ) @@ -136,6 +139,7 @@ fun NormalChatNote( canPreview: Boolean = true, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -255,6 +259,7 @@ fun NormalChatNote( availableBubbleSize, showDetails, accountViewModel, + newPostViewModel, nav, ) } @@ -265,6 +270,7 @@ fun NormalChatNote( popupExpanded = popupExpanded, onDismiss = { popupExpanded = false }, accountViewModel = accountViewModel, + newPostViewModel = newPostViewModel, ) } } @@ -282,6 +288,7 @@ private fun RenderBubble( availableBubbleSize: MutableState, showDetails: State, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, ) { val bubbleSize = remember { mutableIntStateOf(0) } @@ -311,6 +318,7 @@ private fun RenderBubble( canPreview, showDetails, accountViewModel, + newPostViewModel, nav, ) } @@ -329,6 +337,7 @@ private fun MessageBubbleLines( canPreview: Boolean, showDetails: State, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, ) { if (drawAuthorInfo) { @@ -345,6 +354,7 @@ private fun MessageBubbleLines( innerQuote = innerQuote, backgroundBubbleColor = backgroundBubbleColor, accountViewModel = accountViewModel, + newPostViewModel = newPostViewModel, nav = nav, onWantsToReply = onWantsToReply, ) @@ -363,6 +373,9 @@ private fun MessageBubbleLines( bubbleSize = bubbleSize, availableBubbleSize = availableBubbleSize, firstColumn = { + if (baseNote.isDraft()) { + DisplayDraftChat() + } IncognitoBadge(baseNote) ChatTimeAgo(baseNote) RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav) @@ -394,11 +407,12 @@ private fun RenderReplyRow( innerQuote: Boolean, backgroundBubbleColor: MutableState, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { if (!innerQuote && note.replyTo?.lastOrNull() != null) { - RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) + RenderReply(note, backgroundBubbleColor, accountViewModel, newPostViewModel, nav, onWantsToReply) } } @@ -407,6 +421,7 @@ private fun RenderReply( note: Note, backgroundBubbleColor: MutableState, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -425,6 +440,7 @@ private fun RenderReply( innerQuote = true, parentBackgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, + newPostViewModel = newPostViewModel, nav = nav, onWantsToReply = onWantsToReply, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index f4a351ef7..f34c99465 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map @@ -105,8 +107,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.Size25dp import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size34dp @@ -122,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyBackground import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.events.AppDefinitionEvent @@ -238,7 +244,7 @@ fun AcceptableNote( nav = nav, ) else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { showPopup, -> CheckNewAndRenderNote( @@ -273,7 +279,7 @@ fun AcceptableNote( is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { showPopup, -> CheckNewAndRenderNote( @@ -868,6 +874,29 @@ fun DisplayOtsIfInOriginal( } } +@Composable +fun DisplayDraft() { + Text( + "Draft", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + modifier = HalfStartPadding, + ) +} + +@Composable +fun DisplayDraftChat() { + Text( + "Draft", + color = MaterialTheme.colorScheme.placeholderText, + modifier = HalfEndPadding, + fontWeight = FontWeight.Bold, + fontSize = Font12SP, + maxLines = 1, + ) +} + @Composable fun FirstUserInfoRow( baseNote: Note, @@ -910,6 +939,10 @@ fun FirstUserInfoRow( } } + if (baseNote.isDraft()) { + DisplayDraft() + } + TimeAgo(baseNote) MoreOptionsButton(baseNote, editState, accountViewModel, nav) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index d0209765b..f30e2c4fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FormatQuote import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove @@ -84,6 +85,8 @@ import androidx.core.graphics.ColorUtils import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog @@ -132,6 +135,7 @@ val externalLinkForNote = { note: Note -> fun LongPressToQuickAction( baseNote: Note, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, content: @Composable (() -> Unit) -> Unit, ) { val popupExpanded = remember { mutableStateOf(false) } @@ -140,7 +144,7 @@ fun LongPressToQuickAction( content(showPopup) - NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) + NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel, newPostViewModel) } @Composable @@ -149,20 +153,24 @@ fun NoteQuickActionMenu( popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, ) { val showSelectTextDialog = remember { mutableStateOf(false) } val showDeleteAlertDialog = remember { mutableStateOf(false) } val showBlockAlertDialog = remember { mutableStateOf(false) } val showReportDialog = remember { mutableStateOf(false) } + val editDraftDialog = remember { mutableStateOf(false) } if (popupExpanded) { RenderMainPopup( accountViewModel, + newPostViewModel, note, onDismiss, showBlockAlertDialog, showDeleteAlertDialog, showReportDialog, + editDraftDialog, ) } @@ -199,16 +207,29 @@ fun NoteQuickActionMenu( onDismiss() } } + + if (editDraftDialog.value) { + NewPostView( + onClose = { + editDraftDialog.value = false + }, + accountViewModel = accountViewModel, + draft = note, + nav = { }, + ) + } } @Composable private fun RenderMainPopup( accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel?, note: Note, onDismiss: () -> Unit, showBlockAlertDialog: MutableState, showDeleteAlertDialog: MutableState, showReportDialog: MutableState, + editDraftDialog: MutableState, ) { val context = LocalContext.current val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f) @@ -279,6 +300,22 @@ private fun RenderMainPopup( } } + if (note.isDraft()) { + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + Icons.Default.Edit, + stringResource(R.string.edit_draft), + ) { + if (newPostViewModel != null) { + newPostViewModel.load(accountViewModel, null, null, null, null, note) + onDismiss() + } else { + editDraftDialog.value = true + onDismiss() + } + } + } + if (!isOwnNote) { VerticalDivider(color = primaryLight) @@ -389,14 +426,20 @@ fun NoteQuickActionItem( onClick: () -> Unit, ) { Column( - modifier = Modifier.size(70.dp).clickable { onClick() }, + modifier = + Modifier + .size(70.dp) + .clickable { onClick() }, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( imageVector = icon, contentDescription = null, - modifier = Modifier.size(24.dp).padding(bottom = 5.dp), + modifier = + Modifier + .size(24.dp) + .padding(bottom = 5.dp), tint = Color.White, ) Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center) @@ -527,7 +570,10 @@ fun QuickActionAlertDialog( text = { Text(textContent) }, confirmButton = { Row( - modifier = Modifier.padding(all = 8.dp).fillMaxWidth(), + modifier = + Modifier + .padding(all = 8.dp) + .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { TextButton(onClick = onClickDontShowAgain) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 0e4fbeb89..e881f6909 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -310,7 +310,12 @@ fun RenderZapRaiser( } LinearProgressIndicator( - modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) }, + modifier = + remember(details) { + Modifier + .fillMaxWidth() + .height(if (details) 24.dp else 4.dp) + }, color = color, progress = { zapraiserStatus.progress }, ) @@ -590,6 +595,13 @@ fun ReplyReaction( IconButton( modifier = iconSizeModifier, onClick = { + if (baseNote.isDraft()) { + accountViewModel.toast( + R.string.draft_note, + R.string.it_s_not_possible_to_reply_to_a_draft_note, + ) + return@IconButton + } if (accountViewModel.isWriteable()) { onPress() } else { @@ -776,7 +788,8 @@ fun LikeReaction( Box( contentAlignment = Center, modifier = - Modifier.size(iconSize) + Modifier + .size(iconSize) .combinedClickable( role = Role.Button, interactionSource = remember { MutableInteractionSource() }, @@ -784,6 +797,7 @@ fun LikeReaction( onClick = { likeClick( accountViewModel, + baseNote, onMultipleChoices = { wantsToReact = true }, onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, ) @@ -886,9 +900,17 @@ fun ObserveLikeText( private fun likeClick( accountViewModel: AccountViewModel, + baseNote: Note, onMultipleChoices: () -> Unit, onWantsToSignReaction: () -> Unit, ) { + if (baseNote.isDraft()) { + accountViewModel.toast( + R.string.draft_note, + R.string.it_s_not_possible_to_react_to_a_draft_note, + ) + return + } if (accountViewModel.account.reactionChoices.isEmpty()) { accountViewModel.toast( R.string.no_reactions_setup, @@ -1082,6 +1104,14 @@ fun zapClick( onError: (String, String) -> Unit, onPayViaIntent: (ImmutableList) -> Unit, ) { + if (baseNote.isDraft()) { + accountViewModel.toast( + R.string.draft_note, + R.string.it_s_not_possible_to_zap_to_a_draft_note, + ) + return + } + if (accountViewModel.account.zapAmountChoices.isEmpty()) { accountViewModel.toast( context.getString(R.string.error_dialog_zap_error), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt index 021896dea..fd24262d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/WatchNoteEvent.kt @@ -52,6 +52,7 @@ fun WatchNoteEvent( LongPressToQuickAction( baseNote = baseNote, accountViewModel = accountViewModel, + newPostViewModel = null, ) { showPopup -> BlankNote( remember { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 3de55daec..78ed3f3d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -179,7 +179,7 @@ fun ZapCustomDialog( ) ZapButton( - isActive = postViewModel.canSend(), + isActive = postViewModel.canSend() && !baseNote.isDraft(), ) { accountViewModel.zap( baseNote, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 5fc6a69ed..11917bbcb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -185,6 +185,7 @@ class AddBountyAmountViewModel : ViewModel() { root = null, directMentions = setOf(), forkedFrom = null, + draftTag = null, ) nextAmount = TextFieldValue("") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 27d37e018..9683bb3c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -45,6 +45,7 @@ import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.EditPostView +import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.note.externalLinkForNote @@ -122,6 +123,11 @@ fun NoteDropDownMenu( mutableStateOf(false) } + val wantsToEditDraft = + remember { + mutableStateOf(false) + } + if (wantsToEditPost.value) { // avoids changing while drafting a note and a new event shows up. val versionLookingAt = @@ -141,6 +147,18 @@ fun NoteDropDownMenu( ) } + if (wantsToEditDraft.value) { + NewPostView( + onClose = { + popupExpanded.value = false + wantsToEditDraft.value = false + }, + accountViewModel = accountViewModel, + draft = note, + nav = nav, + ) + } + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -219,7 +237,15 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) - if (note.event is TextNoteEvent) { + if (note.isDraft()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit_draft)) }, + onClick = { + wantsToEditDraft.value = true + }, + ) + } + if (note.event is TextNoteEvent && !note.isDraft()) { if (state.isLoggedUser) { DropdownMenuItem( text = { Text(stringResource(R.string.edit_post)) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 89c5c6b52..83760f6a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.FeedPadding @@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding fun RefreshingChatroomFeedView( viewModel: FeedViewModel, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit, @@ -59,6 +61,7 @@ fun RefreshingChatroomFeedView( RenderChatroomFeedView( viewModel, accountViewModel, + newPostViewModel, listState, nav, routeForLastRead, @@ -72,6 +75,7 @@ fun RefreshingChatroomFeedView( fun RenderChatroomFeedView( viewModel: FeedViewModel, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel, listState: LazyListState, nav: (String) -> Unit, routeForLastRead: String, @@ -91,6 +95,7 @@ fun RenderChatroomFeedView( ChatroomFeedLoaded( state, accountViewModel, + newPostViewModel, listState, nav, routeForLastRead, @@ -108,6 +113,7 @@ fun RenderChatroomFeedView( fun ChatroomFeedLoaded( state: FeedState.Loaded, accountViewModel: AccountViewModel, + newPostViewModel: NewPostViewModel, listState: LazyListState, nav: (String) -> Unit, routeForLastRead: String, @@ -130,6 +136,7 @@ fun ChatroomFeedLoaded( baseNote = item, routeForLastRead = routeForLastRead, accountViewModel = accountViewModel, + newPostViewModel = newPostViewModel, nav = nav, onWantsToReply = onWantsToReply, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index dadf8f9a1..3e427b4a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.BlankNote +import com.vitorpamplona.amethyst.ui.note.DisplayDraft import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal import com.vitorpamplona.amethyst.ui.note.HiddenNote import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -446,6 +447,10 @@ fun NoteMaster( DisplayPoW(pow) } + if (note.isDraft()) { + DisplayDraft() + } + DisplayOtsIfInOriginal(note, editState, accountViewModel) } } @@ -605,7 +610,7 @@ fun NoteMaster( ReactionsRow(note, true, editState, accountViewModel, nav) } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel, null) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7e5f9d8a4..d4d03478d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1209,6 +1209,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View baseNote: Note, onMore: () -> Unit, ) { + if (baseNote.isDraft()) { + toast( + R.string.draft_note, + R.string.it_s_not_possible_to_quote_to_a_draft_note, + ) + return + } + if (isWriteable()) { if (hasBoosted(baseNote)) { deleteBoostsTo(baseNote) @@ -1304,6 +1312,11 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } + suspend fun deleteDraft(draftTag: String) { + val notes = LocalCache.draftNotes(draftTag) + account.delete(notes) + } + val bechLinkCache = CachedLoadedBechLink(this) class CachedLoadedBechLink(val accountViewModel: AccountViewModel) : GenericBaseCache(20) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 774a2c432..c48b01841 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -163,12 +163,17 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.UUID @Composable fun ChannelScreen( @@ -187,6 +192,7 @@ fun ChannelScreen( } } +@OptIn(FlowPreview::class) @Composable fun PrepareChannelViewModels( baseChannel: Channel, @@ -204,8 +210,21 @@ fun PrepareChannelViewModels( ) val channelScreenModel: NewPostViewModel = viewModel() + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + channelScreenModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + channelScreenModel.sendPost(localDraft = channelScreenModel.draftTag) + } + } + } + channelScreenModel.accountViewModel = accountViewModel channelScreenModel.account = accountViewModel.account + channelScreenModel.originalNote = LocalCache.getNoteIfExists(baseChannel.idHex) ChannelScreen( channel = baseChannel, @@ -287,6 +306,7 @@ fun ChannelScreen( RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, + newPostViewModel = newPostModel, nav = nav, routeForLastRead = "Channel/${channel.idHex}", onWantsToReply = { replyTo.value = it }, @@ -295,7 +315,7 @@ fun ChannelScreen( Spacer(modifier = DoubleVertSpacer) - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } } val scope = rememberCoroutineScope() @@ -323,6 +343,7 @@ fun ChannelScreen( mentions = tagger.pTags, wantsToMarkAsSensitive = false, nip94attachments = usedAttachments, + draftTag = null, ) } else if (channel is LiveActivitiesChannel) { accountViewModel.account.sendLiveMessage( @@ -332,10 +353,13 @@ fun ChannelScreen( mentions = tagger.pTags, wantsToMarkAsSensitive = false, nip94attachments = usedAttachments, + draftTag = null, ) } newPostModel.message = TextFieldValue("") replyTo.value = null + accountViewModel.deleteDraft(newPostModel.draftTag) + newPostModel.draftTag = UUID.randomUUID().toString() feedViewModel.sendToTop() } } @@ -346,6 +370,7 @@ fun ChannelScreen( fun DisplayReplyingToNote( replyingNote: Note?, accountViewModel: AccountViewModel, + newPostModel: NewPostViewModel, nav: (String) -> Unit, onCancel: () -> Unit, ) { @@ -364,6 +389,7 @@ fun DisplayReplyingToNote( null, innerQuote = true, accountViewModel = accountViewModel, + newPostViewModel = newPostModel, nav = nav, onWantsToReply = {}, ) @@ -665,7 +691,12 @@ fun ShowVideoStreaming( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - modifier = remember { Modifier.fillMaxWidth().heightIn(min = 50.dp, max = 300.dp) }, + modifier = + remember { + Modifier + .fillMaxWidth() + .heightIn(min = 50.dp, max = 300.dp) + }, ) { val zoomableUrlVideo = remember(streamingInfo) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 2e378f268..b9b1c6d83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -116,14 +116,21 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.encoders.Hex +import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.findURLs import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.UUID @Composable fun ChatroomScreen( @@ -206,6 +213,7 @@ fun LoadRoomByAuthor( content(room) } +@OptIn(FlowPreview::class) @Composable fun PrepareChatroomViewModels( room: ChatroomKey, @@ -230,8 +238,20 @@ fun PrepareChatroomViewModels( if (newPostModel.requiresNIP24) { newPostModel.nip24 = true } + room.users.forEach { + newPostModel.toUsers = TextFieldValue(newPostModel.toUsers.text + " @${Hex.decode(it).toNpub()}") + } LaunchedEffect(key1 = newPostModel) { + launch(Dispatchers.IO) { + newPostModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + newPostModel.sendPost(localDraft = newPostModel.draftTag) + } + } + launch(Dispatchers.IO) { val hasNIP24 = accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any { @@ -313,21 +333,28 @@ fun ChatroomScreen( RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, + newPostViewModel = newPostModel, nav = nav, routeForLastRead = "Room/${room.hashCode()}", - onWantsToReply = { replyTo.value = it }, + onWantsToReply = { + replyTo.value = it + newPostModel.originalNote = it + }, ) } Spacer(modifier = Modifier.height(10.dp)) - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } } val scope = rememberCoroutineScope() // LAST ROW PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { scope.launch(Dispatchers.IO) { + accountViewModel.deleteDraft(newPostModel.draftTag) + newPostModel.draftTag = UUID.randomUUID().toString() + val urls = findURLs(newPostModel.message.text) val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } @@ -339,6 +366,7 @@ fun ChatroomScreen( mentions = null, wantsToMarkAsSensitive = false, nip94attachments = usedAttachments, + draftTag = null, ) } else { accountViewModel.account.sendPrivateMessage( @@ -348,6 +376,7 @@ fun ChatroomScreen( mentions = null, wantsToMarkAsSensitive = false, nip94attachments = usedAttachments, + draftTag = null, ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index e19db38ee..e1381f1d7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -93,6 +93,7 @@ val Size40dp = 40.dp val Size55dp = 55.dp val Size75dp = 75.dp +val HalfEndPadding = Modifier.padding(end = 5.dp) val HalfStartPadding = Modifier.padding(start = 5.dp) val StdStartPadding = Modifier.padding(start = 10.dp) val StdTopPadding = Modifier.padding(top = 10.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ecca14c7..fde9df12d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -733,6 +733,8 @@ Could not download uploaded media from the server Could not prepare local file to upload: %1$s + Edit draft + Login with QR Code Route Home @@ -819,4 +821,9 @@ Play username as audio Scan QR code Navigate to the third-party wallet provider Alby + It\'s not possible to reply a draft note + It\'s not possible to quote a draft note + It\'s not possible to react a draft note + It\'s not possible to zap a draft note + Draft Note diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt index c844be2d8..7000f296c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChannelMessageEvent.kt @@ -60,6 +60,7 @@ class ChannelMessageEvent( zapRaiserAmount: Long?, geohash: String? = null, nip94attachments: List? = null, + isDraft: Boolean, onReady: (ChannelMessageEvent) -> Unit, ) { val tags = @@ -87,7 +88,7 @@ class ChannelMessageEvent( arrayOf("alt", ALT), ) - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt index 1c2566d7b..805f5884f 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ChatMessageEvent.kt @@ -82,6 +82,7 @@ class ChatMessageEvent( signer: NostrSigner, createdAt: Long = TimeUtils.now(), nip94attachments: List? = null, + isDraft: Boolean, onReady: (ChatMessageEvent) -> Unit, ) { val tags = mutableListOf>() @@ -106,7 +107,7 @@ class ChatMessageEvent( } // tags.add(arrayOf("alt", alt)) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt index cd8bff93e..0fa5bdf21 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/ClassifiedsEvent.kt @@ -113,6 +113,7 @@ class ClassifiedsEvent( nip94attachments: List? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + isDraft: Boolean, onReady: (ClassifiedsEvent) -> Unit, ) { val tags = mutableListOf>() @@ -192,7 +193,7 @@ class ClassifiedsEvent( } tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt new file mode 100644 index 000000000..742ca64d7 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2024 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.quartz.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class DraftEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + @Transient private var decryptedContent: Map = mapOf() + + @Transient private var citedNotesCache: Set? = null + + fun replyTos(): List { + val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] } + val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1) + val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1) + + val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot) + + return if (newStyleReplyTos.isNotEmpty()) { + newStyleReplyTos + } else { + oldStylePositional + } + } + + fun findCitations(): Set { + citedNotesCache?.let { + return it + } + + val citations = mutableSetOf() + // Removes citations from replies: + val matcher = tagSearch.matcher(content) + while (matcher.find()) { + try { + val tag = matcher.group(1)?.let { tags[it.toInt()] } + if (tag != null && tag.size > 1 && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag.size > 1 && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + + val matcher2 = Nip19Bech32.nip19regex.matcher(content) + while (matcher2.find()) { + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + if (type != null) { + val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity + + if (parsed != null) { + when (parsed) { + is Nip19Bech32.NEvent -> citations.add(parsed.hex) + is Nip19Bech32.NAddress -> citations.add(parsed.atag) + is Nip19Bech32.Note -> citations.add(parsed.hex) + is Nip19Bech32.NEmbed -> citations.add(parsed.event.id) + } + } + } + } + + citedNotesCache = citations + return citations + } + + fun tagsWithoutCitations(): List { + val repliesTo = replyTos() + val tagAddresses = + taggedAddresses().filter { + it.kind != CommunityDefinitionEvent.KIND && + it.kind != WikiNoteEvent.KIND + }.map { it.toTag() } + if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList() + + val citations = findCitations() + + return if (citations.isEmpty()) { + repliesTo + tagAddresses + } else { + repliesTo.filter { it !in citations } + } + } + + fun cachedContentFor(): Event? { + return decryptedContent[dTag()] + } + + fun plainContent( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + decryptedContent[dTag()]?.let { + onReady(it) + return + } + + signer.nip44Decrypt(content, signer.pubKey) { retVal -> + val event = runCatching { fromJson(retVal) }.getOrNull() ?: return@nip44Decrypt + decryptedContent = decryptedContent + Pair(dTag(), event) + + onReady(event) + } + } + + companion object { + const val KIND = 31234 + + fun create( + dTag: String, + originalNote: EventInterface, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + tags.add(arrayOf("d", dTag)) + tags.add(arrayOf("k", "${originalNote.kind()}")) + tags.addAll(originalNote.tags().filter { it.size > 1 && it[0] == "e" }) + tags.addAll(originalNote.tags().filter { it.size > 1 && it[0] == "a" }) + + signer.nip44Encrypt(originalNote.toJson(), signer.pubKey) { encryptedContent -> + signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 306277d04..d923354c8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -79,6 +79,7 @@ class EventFactory { CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig) ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig) DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) + DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt index f083c895f..88d4f5eac 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GitReplyEvent.kt @@ -94,6 +94,7 @@ class GitReplyEvent( forkedFrom: Event? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + isDraft: Boolean, onReady: (GitReplyEvent) -> Unit, ) { val tags = mutableListOf>() @@ -156,7 +157,7 @@ class GitReplyEvent( } tags.add(arrayOf("alt", "a git issue reply")) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt index 4f4b46eb6..c6263aee9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/LiveActivitiesChatMessageEvent.kt @@ -72,6 +72,7 @@ class LiveActivitiesChatMessageEvent( zapRaiserAmount: Long?, geohash: String? = null, nip94attachments: List? = null, + isDraft: Boolean, onReady: (LiveActivitiesChatMessageEvent) -> Unit, ) { val content = message @@ -98,7 +99,7 @@ class LiveActivitiesChatMessageEvent( } tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt index 5ff107a50..6e69a305d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/NIP24Factory.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.events -import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.signers.NostrSigner @@ -78,6 +77,7 @@ class NIP24Factory { zapRaiserAmount: Long? = null, geohash: String? = null, nip94attachments: List? = null, + draftTag: String? = null, onReady: (Result) -> Unit, ) { val senderPublicKey = signer.pubKey @@ -93,15 +93,25 @@ class NIP24Factory { markAsSensitive = markAsSensitive, zapRaiserAmount = zapRaiserAmount, geohash = geohash, + isDraft = draftTag != null, nip94attachments = nip94attachments, ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + if (draftTag != null) { onReady( Result( msg = senderMessage, - wraps = wraps, + wraps = listOf(), ), ) + } else { + createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> + onReady( + Result( + msg = senderMessage, + wraps = wraps, + ), + ) + } } } } @@ -155,49 +165,4 @@ class NIP24Factory { } } } - - fun createTextNoteNIP24( - msg: String, - to: List, - signer: NostrSigner, - replyTos: List? = null, - mentions: List? = null, - addresses: List?, - extraTags: List?, - zapReceiver: List? = null, - markAsSensitive: Boolean = false, - replyingTo: String?, - root: String?, - directMentions: Set, - zapRaiserAmount: Long? = null, - geohash: String? = null, - onReady: (Result) -> Unit, - ) { - val senderPublicKey = signer.pubKey - - TextNoteEvent.create( - msg = msg, - signer = signer, - replyTos = replyTos, - mentions = mentions, - zapReceiver = zapReceiver, - root = root, - extraTags = extraTags, - addresses = addresses, - directMentions = directMentions, - replyingTo = replyingTo, - markAsSensitive = markAsSensitive, - zapRaiserAmount = zapRaiserAmount, - geohash = geohash, - ) { senderMessage -> - createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps -> - onReady( - Result( - msg = senderMessage, - wraps = wraps, - ), - ) - } - } - } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt index 80d63d66a..5e2c62efd 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PollNoteEvent.kt @@ -80,6 +80,7 @@ class PollNoteEvent( zapRaiserAmount: Long?, geohash: String? = null, nip94attachments: List? = null, + isDraft: Boolean, onReady: (PollNoteEvent) -> Unit, ) { val tags = mutableListOf>() @@ -112,7 +113,7 @@ class PollNoteEvent( } tags.add(arrayOf("alt", ALT)) - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt index e44a803ce..942140171 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PrivateDmEvent.kt @@ -126,6 +126,7 @@ class PrivateDmEvent( zapRaiserAmount: Long?, geohash: String? = null, nip94attachments: List? = null, + isDraft: Boolean, onReady: (PrivateDmEvent) -> Unit, ) { var message = msg @@ -167,7 +168,7 @@ class PrivateDmEvent( tags.add(arrayOf("alt", ALT)) signer.nip04Encrypt(message, recipientPubKey) { content -> - signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt index 6088a8678..66521e5b1 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteEvent.kt @@ -60,6 +60,7 @@ class TextNoteEvent( forkedFrom: Event? = null, signer: NostrSigner, createdAt: Long = TimeUtils.now(), + isDraft: Boolean, onReady: (TextNoteEvent) -> Unit, ) { val tags = mutableListOf>() @@ -124,7 +125,7 @@ class TextNoteEvent( } } - signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady) + signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt index 0d9c1f252..8b0cad500 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/ExternalSignerLauncher.kt @@ -171,6 +171,10 @@ class ExternalSignerLauncher( "sign_event", 22242, ), + Permission( + "sign_event", + 31234, + ), Permission( "nip04_encrypt", ), diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt index 5014db2f2..ea1c56aaf 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSigner.kt @@ -32,6 +32,7 @@ abstract class NostrSigner(val pubKey: HexKey) { tags: Array>, content: String, onReady: (T) -> Unit, + isDraft: Boolean = false, ) abstract fun nip04Encrypt( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt index 4f34af999..30cf69b46 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerExternal.kt @@ -40,7 +40,13 @@ class NostrSignerExternal( tags: Array>, content: String, onReady: (T) -> Unit, + isDraft: Boolean, ) { + if (isDraft) { + unsignedEvent(createdAt, kind, tags, content, onReady) + return + } + val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey() val event = @@ -86,6 +92,28 @@ class NostrSignerExternal( } } + fun unsignedEvent( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + val id = Event.generateId(pubKey, createdAt, kind, tags, content) + + onReady( + EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + "", + ) as T, + ) + } + override fun nip04Encrypt( decryptedContent: String, toPublicKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt index 2537287b5..0df60b80d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/signers/NostrSignerInternal.kt @@ -38,9 +38,15 @@ class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toH tags: Array>, content: String, onReady: (T) -> Unit, + isDraft: Boolean, ) { if (keyPair.privKey == null) return + if (isDraft) { + unsignedEvent(createdAt, kind, tags, content, onReady) + return + } + if (isUnsignedPrivateEvent(kind, tags)) { // this is a private zap signPrivateZap(createdAt, kind, tags, content, onReady) @@ -82,6 +88,30 @@ class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toH ) } + fun unsignedEvent( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + onReady: (T) -> Unit, + ) { + if (keyPair.privKey == null) return + + val id = Event.generateId(pubKey, createdAt, kind, tags, content) + + onReady( + EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + "", + ) as T, + ) + } + override fun nip04Encrypt( decryptedContent: String, toPublicKey: HexKey,