From 6e1418cd54119b3970a0d0bcc6d01497bb183fdb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 29 Mar 2024 17:38:31 -0400 Subject: [PATCH] - Adds a Draft Screen - Migrating drafts to new architecture where the Draft Event is sent to the screen instead of the inner event. - Fixes lots of deletion and indexing bugs --- .../vitorpamplona/amethyst/model/Account.kt | 145 ++++--- .../amethyst/model/LocalCache.kt | 403 ++++++++++++------ .../com/vitorpamplona/amethyst/model/Note.kt | 24 +- .../amethyst/model/ThreadAssembler.kt | 90 +++- .../com/vitorpamplona/amethyst/model/User.kt | 12 + .../amethyst/service/Nip96Uploader.kt | 2 - .../service/NostrAccountDataSource.kt | 31 +- .../amethyst/service/NostrDataSource.kt | 9 +- .../service/NostrSingleEventDataSource.kt | 75 +++- .../amethyst/ui/actions/NewPollOption.kt | 13 +- .../amethyst/ui/actions/NewPostView.kt | 47 +- .../amethyst/ui/actions/NewPostViewModel.kt | 270 +++++++----- .../ui/components/ZapRaiserRequest.kt | 10 +- .../dal/DraftEventsFeedFilter.kt} | 37 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/AppTopBar.kt | 2 + .../amethyst/ui/navigation/DrawerContent.kt | 28 +- .../amethyst/ui/navigation/Routes.kt | 7 + .../amethyst/ui/note/ChannelCardCompose.kt | 2 +- .../amethyst/ui/note/ChatroomHeaderCompose.kt | 26 +- .../ui/note/ChatroomMessageCompose.kt | 79 +++- .../amethyst/ui/note/NoteCompose.kt | 77 +++- .../amethyst/ui/note/NoteQuickActionMenu.kt | 11 +- .../amethyst/ui/note/WatchNoteEvent.kt | 1 - .../ui/note/elements/DisplayReward.kt | 35 +- .../amethyst/ui/note/elements/DropDownMenu.kt | 2 +- .../amethyst/ui/screen/ChatroomFeedView.kt | 30 +- .../amethyst/ui/screen/FeedViewModel.kt | 29 +- .../amethyst/ui/screen/ThreadFeedView.kt | 6 +- .../ui/screen/loggedIn/AccountViewModel.kt | 42 +- .../ui/screen/loggedIn/ChannelScreen.kt | 109 ++--- .../ui/screen/loggedIn/ChatroomScreen.kt | 94 ++-- .../ui/screen/loggedIn/DraftListScreen.kt | 82 ++++ app/src/main/res/values/strings.xml | 1 + .../benchmark/GiftWrapReceivingBenchmark.kt | 2 + .../vitorpamplona/quartz/events/DraftEvent.kt | 225 ++++++---- .../com/vitorpamplona/quartz/events/Event.kt | 2 + .../quartz/events/EventInterface.kt | 2 + 38 files changed, 1382 insertions(+), 682 deletions(-) rename app/src/main/java/com/vitorpamplona/amethyst/{model/Drafts.kt => ui/dal/DraftEventsFeedFilter.kt} (50%) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DraftListScreen.kt 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 6a7d123f9..2068e09c5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent import com.vitorpamplona.quartz.events.EmojiUrl import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileServersEvent import com.vitorpamplona.quartz.events.FileStorageEvent @@ -845,18 +846,11 @@ class Account( } } - suspend fun delete(note: Note) { - if (note.isDraft()) { - note.event?.let { - val drafts = LocalCache.getDrafts(it.id()) - return delete(drafts) - } - } else { - return delete(listOf(note)) - } + fun delete(note: Note) { + delete(listOf(note)) } - suspend fun delete(notes: List) { + fun delete(notes: List) { if (!isWriteable()) return val myEvents = notes.filter { it.author == userProfile() } @@ -906,17 +900,10 @@ class Account( fun broadcast(note: Note) { note.event?.let { - if (note.isDraft()) { - val drafts = LocalCache.getDrafts(it.id()) - drafts.forEach { draftNote -> - broadcast(draftNote) - } + if (it is WrappedEvent && it.host != null) { + it.host?.let { hostEvent -> Client.send(hostEvent) } } else { - if (it is WrappedEvent && it.host != null) { - it.host?.let { hostEvent -> Client.send(hostEvent) } - } else { - Client.send(it) - } + Client.send(it) } } } @@ -1366,11 +1353,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it, relayList = relayList) @@ -1428,11 +1417,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it, relayList = relayList) @@ -1454,7 +1445,21 @@ class Account( } } - fun sendPost( + fun deleteDraft(draftTag: String) { + val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) + LocalCache.getAddressableNoteIfExists(key)?.let { + val noteEvent = it.event + if (noteEvent is DraftEvent) { + noteEvent.createDeletedEvent(signer) { + Client.send(it) + LocalCache.justConsume(it, null) + } + } + delete(it) + } + } + + suspend fun sendPost( message: String, replyTo: List?, mentions: List?, @@ -1496,11 +1501,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it, relayList = relayList) @@ -1587,11 +1594,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent, relayList = relayList) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it, relayList = relayList) @@ -1639,11 +1648,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it) @@ -1684,11 +1695,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it) @@ -1756,11 +1769,13 @@ class Account( isDraft = draftTag != 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + } } } else { Client.send(it) @@ -1802,11 +1817,13 @@ class Account( signer = signer, ) { 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()) + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent -> + Client.send(draftEvent) + LocalCache.justConsume(draftEvent, null) + } } } else { broadcastPrivately(it) @@ -2325,7 +2342,11 @@ class Account( } fun cachedDecryptContent(note: Note): String? { - val event = note.event + return cachedDecryptContent(note.event) + } + + fun cachedDecryptContent(event: EventInterface?): String? { + if (event == null) return null return if (event is PrivateDmEvent && isWriteable()) { event.cachedContentFor(signer) 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 21f48c2c4..313b0936e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent +import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf @@ -129,7 +130,6 @@ object LocalCache { val users = LargeCache() val notes = LargeCache() val addressables = LargeCache() - val drafts = ConcurrentHashMap>() val channels = LargeCache() val awaitingPaymentRequests = ConcurrentHashMap Unit>>(10) @@ -142,34 +142,6 @@ 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" } @@ -379,7 +351,7 @@ object LocalCache { return } - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) note.loadEvent(event, author, replyTo) @@ -462,13 +434,7 @@ object LocalCache { return } - val repository = event.repository()?.toTag() - - val replyTo = - event - .tagsWithoutCitations() - .filter { it != repository } - .mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) // println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}") @@ -506,7 +472,7 @@ object LocalCache { return } - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, replyTo) @@ -541,7 +507,7 @@ object LocalCache { return } - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, replyTo) @@ -550,6 +516,58 @@ object LocalCache { } } + fun computeReplyTo(event: Event): List { + return when (event) { + is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) } + is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is LnZapEvent -> + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + (event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList()) + is LnZapRequestEvent -> + event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + is BadgeProfilesEvent -> + event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + + event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } + is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) } + is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + is RepostEvent -> + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + is GenericRepostEvent -> + event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + is CommunityPostApprovalEvent -> event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + is ReactionEvent -> + event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + is ReportEvent -> + event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + is ChannelMessageEvent -> + event + .tagsWithoutCitations() + .filter { it != event.channel() } + .mapNotNull { checkGetOrCreateNote(it) } + is LiveActivitiesChatMessageEvent -> + event + .tagsWithoutCitations() + .filter { it != event.activity()?.toTag() } + .mapNotNull { checkGetOrCreateNote(it) } + + is DraftEvent -> { + event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().mapNotNull { checkGetOrCreateAddressableNote(it.toTag()) } + } + + else -> emptyList() + } + } + fun consume( event: PollNoteEvent, relay: Relay? = null, @@ -570,7 +588,7 @@ object LocalCache { return } - val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) note.loadEvent(event, author, replyTo) @@ -791,9 +809,7 @@ object LocalCache { // Already processed this event. if (note.event?.id() == event.id()) return - val replyTo = - event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } + - event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) } + val replyTo = computeReplyTo(event) if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, replyTo) @@ -812,7 +828,7 @@ object LocalCache { // ${formattedDateTime(event.createdAt)}") val author = getOrCreateUser(event.pubKey) - val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) } + val awardDefinition = computeReplyTo(event) note.loadEvent(event, author, awardDefinition) @@ -872,6 +888,8 @@ object LocalCache { val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) + val replyTos = computeReplyTo(event) + if (version.event == null) { version.loadEvent(event, author, emptyList()) version.moveAllReferencesTo(note) @@ -886,7 +904,7 @@ object LocalCache { if (note.event?.id() == event.id()) return if (event.createdAt > (note.createdAt() ?: 0)) { - note.loadEvent(event, author, emptyList()) + note.loadEvent(event, author, replyTos) refreshObservers(note) } @@ -923,7 +941,7 @@ object LocalCache { // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -947,52 +965,112 @@ object LocalCache { // must be the same author if (deleteNote.author?.pubkeyHex == event.pubKey) { // reverts the add - val mentions = - deleteNote.event - ?.tags() - ?.filter { it.firstOrNull() == "p" } - ?.mapNotNull { it.getOrNull(1) } - ?.mapNotNull { checkGetOrCreateUser(it) } - - mentions?.forEach { user -> user.removeReport(deleteNote) } - - // Counts the replies - deleteNote.replyTo?.forEach { masterNote -> - masterNote.removeReply(deleteNote) - masterNote.removeBoost(deleteNote) - masterNote.removeReaction(deleteNote) - masterNote.removeZap(deleteNote) - masterNote.removeZapPayment(deleteNote) - masterNote.removeReport(deleteNote) - } - - deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) } - - (deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let { - getChannelIfExists(it.toTag())?.removeNote(deleteNote) - } - - if (deleteNote.event is PrivateDmEvent) { - val author = deleteNote.author - val recipient = - (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { - checkGetOrCreateUser(it) - } - - if (recipient != null && author != null) { - author.removeMessage(recipient, deleteNote) - recipient.removeMessage(author, deleteNote) - } - } - - notes.remove(deleteNote.idHex) + deleteNote(deleteNote) deletedAtLeastOne = true } } + val addressList = event.deleteAddresses() + val addressSet = addressList.toSet() + + addressList + .mapNotNull { getAddressableNoteIfExists(it.toTag()) } + .forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) < event.createdAt) { + // Counts the replies + deleteNote(deleteNote) + + addressables.remove(deleteNote.idHex) + + deletedAtLeastOne = true + } + } + + notes.forEach { key, note -> + val noteEvent = note.event + if (noteEvent is AddressableEvent && noteEvent.address() in addressSet) { + if (noteEvent.pubKey() == event.pubKey && noteEvent.createdAt() <= event.createdAt) { + deleteNote(note) + deletedAtLeastOne = true + } + } + } + if (deletedAtLeastOne) { - // refreshObservers() + val note = Note(event.id) + note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList()) + refreshObservers(note) + } + } + + private fun deleteNote(deleteNote: Note) { + val deletedEvent = deleteNote.event + + val mentions = + deleteNote.event + ?.tags() + ?.filter { it.firstOrNull() == "p" } + ?.mapNotNull { it.getOrNull(1) } + ?.mapNotNull { checkGetOrCreateUser(it) } + + mentions?.forEach { user -> user.removeReport(deleteNote) } + + // Counts the replies + deleteNote.replyTo?.forEach { masterNote -> + masterNote.removeReply(deleteNote) + masterNote.removeBoost(deleteNote) + masterNote.removeReaction(deleteNote) + masterNote.removeZap(deleteNote) + masterNote.removeZapPayment(deleteNote) + masterNote.removeReport(deleteNote) + } + + deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) } + + (deletedEvent as? LiveActivitiesChatMessageEvent)?.activity()?.let { + getChannelIfExists(it.toTag())?.removeNote(deleteNote) + } + + if (deletedEvent is PrivateDmEvent) { + val author = deleteNote.author + val recipient = + deletedEvent.verifiedRecipientPubKey()?.let { + checkGetOrCreateUser(it) + } + + if (recipient != null && author != null) { + author.removeMessage(recipient, deleteNote) + recipient.removeMessage(author, deleteNote) + } + } + + if (deletedEvent is DraftEvent) { + deletedEvent.allCache().forEach { + it?.let { + deindexDraftAsRealEvent(deleteNote, it) + } + } + } + + if (deletedEvent is WrappedEvent) { + deleteWraps(deletedEvent) + } + + notes.remove(deleteNote.idHex) + } + + fun deleteWraps(event: WrappedEvent) { + event.host?.let { + // seal + getNoteIfExists(it.id)?.let { + val noteEvent = it.event + if (noteEvent is WrappedEvent) { + deleteWraps(noteEvent) + } + } + notes.remove(it.id) } } @@ -1006,9 +1084,7 @@ object LocalCache { // ${formattedDateTime(event.createdAt)}") val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1028,9 +1104,7 @@ object LocalCache { // ${formattedDateTime(event.createdAt)}") val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1052,7 +1126,7 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) val communities = event.communities() - val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) } + val eventsApproved = computeReplyTo(event) val repliesTo = communities.map { getOrCreateAddressableNote(it) } @@ -1071,9 +1145,7 @@ object LocalCache { if (note.event != null) return val author = getOrCreateUser(event.pubKey) - val repliesTo = - event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1101,9 +1173,7 @@ object LocalCache { if (note.event != null) return val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } - val repliesTo = - event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1202,11 +1272,7 @@ object LocalCache { return } - val replyTo = - event - .tagsWithoutCitations() - .filter { it != event.channel() } - .mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) note.loadEvent(event, author, replyTo) @@ -1245,11 +1311,7 @@ object LocalCache { return } - val replyTo = - event - .tagsWithoutCitations() - .filter { it != event.activity()?.toTag() } - .mapNotNull { checkGetOrCreateNote(it) } + val replyTo = computeReplyTo(event) note.loadEvent(event, author, replyTo) @@ -1279,15 +1341,7 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = - event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + - ( - (zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map { - getOrCreateAddressableNote(it) - } - ?: emptySet() - ) + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1308,9 +1362,7 @@ object LocalCache { val author = getOrCreateUser(event.pubKey) val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = - event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + - event.taggedAddresses().map { getOrCreateAddressableNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -1512,7 +1564,7 @@ object LocalCache { // Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}") - val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + val repliesTo = computeReplyTo(event) note.loadEvent(event, author, repliesTo) @@ -2046,7 +2098,112 @@ object LocalCache { event: DraftEvent, relay: Relay?, ) { - consumeBaseReplaceable(event, relay) + if (!event.isDeleted()) { + consumeBaseReplaceable(event, relay) + + event.allCache().forEach { + it?.let { + indexDraftAsRealEvent(event, it) + } + } + } + } + + fun indexDraftAsRealEvent( + draftWrap: DraftEvent, + draft: Event, + ) { + val note = getOrCreateAddressableNote(draftWrap.address()) + val author = getOrCreateUser(draftWrap.pubKey) + + when (draft) { + is PrivateDmEvent -> { + draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> + author.addMessage(recipient, note) + recipient.addMessage(author, note) + } + } + is ChatMessageEvent -> { + val recipientsHex = draft.recipientsPubKey().plus(draftWrap.pubKey).toSet() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.addMessage(authorGroup, note) + } + } + } + is ChannelMessageEvent -> { + draft.channel()?.let { channelId -> + checkGetOrCreateChannel(channelId)?.let { channel -> + channel.addNote(note) + } + } + } + is TextNoteEvent -> { + val replyTo = computeReplyTo(draft) + val author = getOrCreateUser(draftWrap.pubKey) + note.loadEvent(draftWrap, author, replyTo) + replyTo.forEach { it.addReply(note) } + } + } + } + + fun deindexDraftAsRealEvent( + draftWrap: Note, + draft: Event, + ) { + val author = draftWrap.author ?: return + + when (draft) { + is PrivateDmEvent -> { + draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient -> + author.removeMessage(recipient, draftWrap) + recipient.removeMessage(author, draftWrap) + } + } + is ChatMessageEvent -> { + val recipientsHex = draft.recipientsPubKey().plus(author.pubkeyHex).toSet() + val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet() + + if (recipients.isNotEmpty()) { + recipients.forEach { + val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex) + + val authorGroup = + if (groupMinusRecipient.isEmpty()) { + // note to self + ChatroomKey(persistentSetOf(it.pubkeyHex)) + } else { + ChatroomKey(groupMinusRecipient.toImmutableSet()) + } + + it.removeMessage(authorGroup, draftWrap) + } + } + } + is ChannelMessageEvent -> { + draft.channel()?.let { channelId -> + checkGetOrCreateChannel(channelId)?.let { channel -> + channel.removeNote(draftWrap) + } + } + } + is TextNoteEvent -> { + val replyTo = computeReplyTo(draft) + replyTo.forEach { it.removeReply(draftWrap) } + } + } } fun justConsume( 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 40189567e..5cae304bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -97,6 +98,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) { fun dTag(): String? { return (event as? AddressableEvent)?.dTag() } + + override fun wasOrShouldBeDeletedBy( + deletionEvents: Set, + deletionAddressables: Set, + ): Boolean { + val thisEvent = event + return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id())) + } } @Stable @@ -184,12 +193,7 @@ open class Note(val idHex: String) { open fun createdAt() = event?.createdAt() - fun isDraft(): Boolean { - event?.let { - return it.sig().isBlank() - } - return false - } + fun isDraft() = event is DraftEvent fun loadEvent( event: Event, @@ -935,6 +939,14 @@ open class Note(val idHex: String) { createOrDestroyFlowSync(false) } } + + open fun wasOrShouldBeDeletedBy( + deletionEvents: Set, + deletionAddressables: Set, + ): Boolean { + val thisEvent = event + return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address())) + } } @Stable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index 2dfb7d9cd..d8bfb83f0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.encoders.ATag +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.RepostEvent import kotlin.time.measureTimedValue @@ -78,7 +80,7 @@ class ThreadAssembler { val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet() if (note.event != null) { - val thread = mutableSetOf() + val thread = OnlyLatestVersionSet() val threadRoot = searchRoot(note, thread) ?: note @@ -87,7 +89,7 @@ class ThreadAssembler { // did not added them. note.replies.forEach { loadDown(it, thread) } - thread.toSet() + thread } else { setOf(note) } @@ -109,3 +111,87 @@ class ThreadAssembler { } } } + +class OnlyLatestVersionSet : MutableSet { + val map = hashMapOf() + val set = hashSetOf() + + override fun add(element: Note): Boolean { + val loadedCreatedAt = element.createdAt() + val noteEvent = element.event + + return if (element is AddressableNote && loadedCreatedAt != null) { + innerAdd(element.address, element, loadedCreatedAt) + } else if (noteEvent is AddressableEvent && loadedCreatedAt != null) { + innerAdd(noteEvent.address(), element, loadedCreatedAt) + } else { + set.add(element) + } + } + + private fun innerAdd( + address: ATag, + element: Note, + loadedCreatedAt: Long, + ): Boolean { + val existing = map.get(address) + return if (existing == null) { + map.put(address, loadedCreatedAt) + set.add(element) + } else { + if (loadedCreatedAt > existing) { + map.put(address, loadedCreatedAt) + set.add(element) + } else { + false + } + } + } + + override fun addAll(elements: Collection): Boolean { + return elements.map { add(it) }.any() + } + + override val size: Int + get() = set.size + + override fun clear() { + set.clear() + map.clear() + } + + override fun isEmpty(): Boolean { + return set.isEmpty() + } + + override fun containsAll(elements: Collection): Boolean { + return set.containsAll(elements) + } + + override fun contains(element: Note): Boolean { + return set.contains(element) + } + + override fun iterator(): MutableIterator { + return set.iterator() + } + + override fun retainAll(elements: Collection): Boolean { + return set.retainAll(elements) + } + + override fun removeAll(elements: Collection): Boolean { + return elements.map { remove(it) }.any() + } + + override fun remove(element: Note): Boolean { + element.address()?.let { + map.remove(it) + } + (element.event as? AddressableEvent)?.address()?.let { + map.remove(it) + } + + return set.remove(element) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index bd7c4915a..712ce13e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -277,6 +277,18 @@ class User(val pubkeyHex: String) { } } + fun removeMessage( + room: ChatroomKey, + msg: Note, + ) { + checkNotInMainThread() + val privateChatroom = getOrCreatePrivateChatroom(room) + if (msg in privateChatroom.roomMessages) { + privateChatroom.removeMessageSync(msg) + liveSet?.innerMessages?.invalidateData() + } + } + fun addRelayBeingUsed( relay: Relay, eventTime: Long, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index d4c51113d..923538ed5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -200,8 +200,6 @@ class Nip96Uploader(val account: Account?) { nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) } - println(server.apiUrl.removeSuffix("/") + "/$hash.$extension") - val request = requestBuilder .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}") 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 5e285cd36..62beb62a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -145,7 +145,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(ReportEvent.KIND), + kinds = listOf(DraftEvent.KIND, ReportEvent.KIND), authors = listOf(account.userProfile().pubkeyHex), since = latestEOSEs.users[account.userProfile()] @@ -230,16 +230,6 @@ 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, @@ -277,20 +267,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { is DraftEvent -> { // 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 + if (!event.isDeleted()) { + 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() + // decrypts + event.cachedDraft(account.signer) {} - LocalCache.justConsume(it, relay) - tag?.let { lTag -> - LocalCache.addDraft(lTag, event.id(), it.id()) - } + LocalCache.justConsume(event, relay) } } @@ -376,7 +360,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountSettingsFilter(), createAccountLastPostsListFilter(), createOtherAccountsBaseFilter(), - createDraftsFilter(), ) .ifEmpty { null } } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 0a1623e01..186920d11 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Subscription import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope @@ -293,7 +294,13 @@ abstract class NostrDataSource(val debugName: String) { eventId: String, relay: Relay, ) { - LocalCache.getNoteIfExists(eventId)?.addRelay(relay) + val note = LocalCache.getNoteIfExists(eventId) + val noteEvent = note?.event + if (noteEvent is AddressableEvent) { + LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay) + } else { + note?.addRelay(relay) + } } open fun markAsEOSE( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 659314695..3e56570fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GitReplyEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent @@ -57,29 +58,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { } return groupByEOSEPresence(addressesToWatch).map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = - JsonFilter( - kinds = - listOf( - TextNoteEvent.KIND, - ReactionEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ReportEvent.KIND, - LnZapEvent.KIND, - PollNoteEvent.KIND, - CommunityPostApprovalEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - ), - tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), - since = findMinimumEOSEs(it), - // Max amount of "replies" to download on a specific event. - limit = 1000, - ), + listOf( + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + GenericRepostEvent.KIND, + ReportEvent.KIND, + LnZapEvent.KIND, + PollNoteEvent.KIND, + CommunityPostApprovalEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ), + tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 1000, + ), + ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + DeletionEvent.KIND, + ), + tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 10, + ), + ), ) - } + }.flatten() } private fun createAddressFilter(): List? { @@ -147,6 +164,20 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { limit = 1000, ), ), + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + JsonFilter( + kinds = + listOf( + DeletionEvent.KIND, + ), + tags = mapOf("e" to it.map { it.idHex }), + since = findMinimumEOSEs(it), + // Max amount of "replies" to download on a specific event. + limit = 10, + ), + ), ) }.flatten() } 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 794fa5cbf..086035c0a 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,11 +34,8 @@ 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( @@ -49,10 +46,7 @@ fun NewPollOption( val deleteIcon: @Composable (() -> Unit) = { IconButton( onClick = { - pollViewModel.pollOptions.remove(optionIndex) - pollViewModel.viewModelScope.launch(Dispatchers.IO) { - pollViewModel.saveDraft() - } + pollViewModel.removePollOption(optionIndex) }, ) { Icon( @@ -66,10 +60,7 @@ fun NewPollOption( modifier = Modifier.weight(1F), value = pollViewModel.pollOptions[optionIndex] ?: "", onValueChange = { - pollViewModel.pollOptions[optionIndex] = it - pollViewModel.viewModelScope.launch(Dispatchers.IO) { - pollViewModel.saveDraft() - } + pollViewModel.updatePollOption(optionIndex, it) }, label = { Text( 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 ea68886ee..c20b4d116 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,7 +119,6 @@ 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 @@ -177,7 +176,6 @@ 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 @@ -206,15 +204,18 @@ fun NewPostView( var showRelaysDialog by remember { mutableStateOf(false) } var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } - LaunchedEffect(Unit) { + LaunchedEffect(key1 = postViewModel.draftTag) { launch(Dispatchers.IO) { postViewModel.draftTextChanges .receiveAsFlow() .debounce(1000) .collectLatest { - postViewModel.sendPost(relayList = relayList, localDraft = postViewModel.draftTag) + postViewModel.sendDraft(relayList = relayList) } } + } + + LaunchedEffect(Unit) { launch(Dispatchers.IO) { postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft) @@ -366,7 +367,7 @@ fun NewPostView( } } - if (enableMessageInterface) { + if (postViewModel.wantsDirectMessage) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), @@ -596,10 +597,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) { } MarkAsSensitive(postViewModel) { - postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.toggleMarkAsSensitive() } AddGeoHash(postViewModel) { @@ -846,10 +844,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { MyTextField( value = postViewModel.title, onValueChange = { - postViewModel.title = it - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.updateTitle(it) }, modifier = Modifier.fillMaxWidth(), placeholder = { @@ -886,16 +881,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { modifier = Modifier.fillMaxWidth(), value = postViewModel.price, onValueChange = { - runCatching { - if (it.text.isEmpty()) { - postViewModel.price = TextFieldValue("") - } else if (it.text.toLongOrNull() != null) { - postViewModel.price = it - } - } - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.updatePrice(it) }, placeholder = { Text( @@ -961,10 +947,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second, options = conditionOptions, onSelect = { - postViewModel.condition = conditionTypes[it].first - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.updateCondition(conditionTypes[it].first) }, modifier = Modifier @@ -1030,10 +1013,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { ?: "", options = categoryOptions, onSelect = { - postViewModel.category = TextFieldValue(categoryTypes[it].second) - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second)) }, modifier = Modifier @@ -1070,10 +1050,7 @@ fun SellProduct(postViewModel: NewPostViewModel) { MyTextField( value = postViewModel.locationText, onValueChange = { - postViewModel.locationText = it - postViewModel.viewModelScope.launch(Dispatchers.IO) { - postViewModel.saveDraft() - } + postViewModel.updateLocation(it) }, modifier = Modifier.fillMaxWidth(), placeholder = { 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 e2abf4c81..f16586858 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 @@ -49,12 +49,15 @@ import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.MediaCompressor import com.vitorpamplona.amethyst.ui.components.Split import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.encoders.Hex import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.encoders.toNpub import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageEvent @@ -84,7 +87,8 @@ enum class UserSuggestionAnchor { @Stable open class NewPostViewModel() : ViewModel() { - var draftTag: String = UUID.randomUUID().toString() + var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) + var accountViewModel: AccountViewModel? = null var account: Account? = null var requiresNIP24: Boolean = false @@ -192,8 +196,17 @@ open class NewPostViewModel() : ViewModel() { this.accountViewModel = accountViewModel this.account = accountViewModel.account - if (draft != null) { - loadFromDraft(draft, accountViewModel) + val noteEvent = draft?.event + val noteAuthor = draft?.author + + if (draft != null && noteEvent is DraftEvent && noteAuthor != null) { + accountViewModel.createTempDraftNote(noteEvent, noteAuthor) { innerNote -> + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag = oldTag + } + loadFromDraft(innerNote, accountViewModel) + } } else { originalNote = replyingTo replyingTo?.let { replyNote -> @@ -227,14 +240,6 @@ open class NewPostViewModel() : ViewModel() { 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() @@ -313,16 +318,13 @@ open class NewPostViewModel() : ViewModel() { accountViewModel: AccountViewModel, ) { Log.d("draft", draft.event!!.toJson()) - - draftTag = LocalCache.drafts.filter { - it.value.any { it.eventId == draft.event?.id() } - }.keys.firstOrNull() ?: draftTag + val draftEvent = draft.event ?: return canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null contentToAddUrl = null - val localfowardZapTo = draft.event?.tags()?.filter { it.size > 1 && it[0] == "zap" } ?: listOf() + val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" } forwardZapTo = Split() localfowardZapTo.forEach { val user = LocalCache.getOrCreateUser(it[1]) @@ -332,9 +334,9 @@ open class NewPostViewModel() : ViewModel() { forwardZapToEditting = TextFieldValue("") wantsForwardZapTo = localfowardZapTo.isNotEmpty() - 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() + wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" } + wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" } + val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" } wantsZapraiser = zapraiser.isNotEmpty() zapRaiserAmount = null if (wantsZapraiser) { @@ -342,25 +344,34 @@ open class NewPostViewModel() : ViewModel() { } eTags = - draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }?.mapNotNull { + draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { val note = LocalCache.checkGetOrCreateNote(it[1]) note } - pTags = - draft.event?.tags()?.filter { it.size > 1 && it[0] == "p" }?.map { - LocalCache.getOrCreateUser(it[1]) - } + if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) { + pTags = + draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map { + LocalCache.getOrCreateUser(it[1]) + } + } - draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "fork" }?.forEach { + draftEvent.tags().filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(3) == "fork" }.forEach { val note = LocalCache.checkGetOrCreateNote(it[1]) forkedFromNote = note } originalNote = - draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }?.map { + draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map { LocalCache.checkGetOrCreateNote(it[1]) - }?.firstOrNull() + }.firstOrNull() + + if (originalNote == null) { + originalNote = + draftEvent.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 @@ -368,14 +379,14 @@ open class NewPostViewModel() : ViewModel() { wantsForwardZapTo = true } - val polls = draft.event?.tags()?.filter { it.size > 1 && it[0] == "poll_option" } ?: emptyList() + val polls = draftEvent.tags().filter { it.size > 1 && it[0] == "poll_option" } 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() + val minMax = draftEvent.tags().filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } minMax.forEach { if (it[0] == "value_maximum") { valueMaximum = it[1].toInt() @@ -384,33 +395,56 @@ open class NewPostViewModel() : ViewModel() { } } - wantsProduct = draft.event?.kind() == 30402 + wantsProduct = draftEvent.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() ?: "") + title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "") + price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "") + category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "") + locationText = TextFieldValue(draftEvent.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() + it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull() } ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW + wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent + + draftEvent.subject()?.let { + subject = TextFieldValue() + } + message = - if (draft.event is PrivateDmEvent) { - val event = draft.event as PrivateDmEvent - TextFieldValue(event.cachedContentFor(accountViewModel.account.signer) ?: "") + if (draftEvent is PrivateDmEvent) { + val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } + toUsers = TextFieldValue("@$recepientNpub") + TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "") } else { - TextFieldValue(draft.event?.content() ?: "") + TextFieldValue(draftEvent.content()) } - nip24 = draft.event is ChatMessageEvent + requiresNIP24 = draftEvent is ChatMessageEvent + nip24 = draftEvent is ChatMessageEvent + + if (draftEvent is ChatMessageEvent) { + toUsers = + TextFieldValue( + draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + } + urlPreview = findUrlInMessage() } - fun sendPost( - relayList: List? = null, - localDraft: String? = null, - ) { - viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList, localDraft) } + fun sendPost(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { + innerSendPost(relayList, null) + accountViewModel?.deleteDraft(draftTag) + cancel() + } + } + + fun sendDraft(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { + innerSendPost(relayList, draftTag) + } } private suspend fun innerSendPost( @@ -422,8 +456,7 @@ open class NewPostViewModel() : ViewModel() { return } - val tagger = - NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) + val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!) tagger.run() val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!) @@ -526,6 +559,7 @@ open class NewPostViewModel() : ViewModel() { zapRaiserAmount = localZapRaiserAmount, geohash = geoHash, nip94attachments = usedAttachments, + draftTag = localDraft, ) } else if (!dmUsers.isNullOrEmpty()) { if (nip24 || dmUsers.size > 1) { @@ -599,19 +633,19 @@ open class NewPostViewModel() : ViewModel() { } else { if (wantsPoll) { account?.sendPoll( - tagger.message, - tagger.eTags, - tagger.pTags, - pollOptions, - valueMaximum, - valueMinimum, - consensusThreshold, - closedAt, - zapReceiver, - wantsToMarkAsSensitive, - localZapRaiserAmount, - relayList, - geoHash, + message = tagger.message, + replyTo = tagger.eTags, + mentions = tagger.pTags, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + relayList = relayList, + geohash = geoHash, nip94attachments = usedAttachments, draftTag = localDraft, ) @@ -673,9 +707,6 @@ open class NewPostViewModel() : ViewModel() { ) } } - if (localDraft == null) { - cancel() - } } fun upload( @@ -759,7 +790,6 @@ open class NewPostViewModel() : ViewModel() { urlPreview = null isUploadingImage = false pTags = null - eTags = null wantsDirectMessage = false @@ -777,6 +807,9 @@ open class NewPostViewModel() : ViewModel() { wantsProduct = false condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW + locationText = TextFieldValue("") + title = TextFieldValue("") + category = TextFieldValue("") price = TextFieldValue("") wantsForwardZapTo = false @@ -788,13 +821,16 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() userSuggestionAnchor = null userSuggestionsMainMessage = null - originalNote = null + draftTag = UUID.randomUUID().toString() + + NostrSearchEventOrUserDataSource.clear() + } + + fun deleteDraft() { viewModelScope.launch(Dispatchers.IO) { accountViewModel?.deleteDraft(draftTag) } - - NostrSearchEventOrUserDataSource.clear() } open fun findUrlInMessage(): String? { @@ -809,8 +845,8 @@ open class NewPostViewModel() : ViewModel() { pTags = pTags?.filter { it != userToRemove } } - open suspend fun saveDraft() { - draftTextChanges.send("") + private fun saveDraft() { + draftTextChanges.trySend("") } open fun updateMessage(it: TextFieldValue) { @@ -836,9 +872,7 @@ open class NewPostViewModel() : ViewModel() { } } - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } open fun updateToUsers(it: TextFieldValue) { @@ -862,16 +896,12 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } } - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } open fun updateSubject(it: TextFieldValue) { subject = it - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } open fun updateZapForwardTo(it: TextFieldValue) { @@ -898,9 +928,7 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } } - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } open fun autocompleteWithUser(item: User) { @@ -947,9 +975,7 @@ open class NewPostViewModel() : ViewModel() { userSuggestions = emptyList() } - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } private fun newStateMapPollOptions(): SnapshotStateMap { @@ -1020,9 +1046,7 @@ open class NewPostViewModel() : ViewModel() { message = message.insertUrlAtCursor(imageUrl) urlPreview = findUrlInMessage() - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } }, onError = { @@ -1067,9 +1091,7 @@ open class NewPostViewModel() : ViewModel() { } urlPreview = findUrlInMessage() - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } }, onError = { @@ -1090,7 +1112,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() } + saveDraft() } viewModelScope.launch(Dispatchers.IO) { locUtil?.start() } } @@ -1116,9 +1138,7 @@ open class NewPostViewModel() : ViewModel() { nip24 = !nip24 } if (message.text.isNotBlank()) { - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } } @@ -1139,9 +1159,7 @@ open class NewPostViewModel() : ViewModel() { } checkMinMax() - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } fun updateMaxZapAmountForPoll(textMax: String) { @@ -1161,9 +1179,7 @@ open class NewPostViewModel() : ViewModel() { } checkMinMax() - viewModelScope.launch(Dispatchers.IO) { - saveDraft() - } + saveDraft() } fun checkMinMax() { @@ -1182,6 +1198,60 @@ open class NewPostViewModel() : ViewModel() { ) { forwardZapTo.updatePercentage(index, sliderValue) } + + fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount = newAmount + saveDraft() + } + + fun removePollOption(optionIndex: Int) { + pollOptions.remove(optionIndex) + saveDraft() + } + + fun updatePollOption( + optionIndex: Int, + text: String, + ) { + pollOptions[optionIndex] = text + saveDraft() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + saveDraft() + } + + fun updateTitle(it: TextFieldValue) { + title = it + saveDraft() + } + + fun updatePrice(it: TextFieldValue) { + runCatching { + if (it.text.isEmpty()) { + price = TextFieldValue("") + } else if (it.text.toLongOrNull() != null) { + price = it + } + } + saveDraft() + } + + fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) { + condition = newCondition + saveDraft() + } + + fun updateCategory(value: TextFieldValue) { + category = value + saveDraft() + } + + fun updateLocation(it: TextFieldValue) { + locationText = it + saveDraft() + } } enum class GeohashPrecision(val digits: Int) { 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 a2715ec99..b72107023 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,7 +39,6 @@ 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 @@ -47,8 +46,6 @@ 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( @@ -97,12 +94,9 @@ fun ZapRaiserRequest( onValueChange = { runCatching { if (it.isEmpty()) { - newPostViewModel.zapRaiserAmount = null + newPostViewModel.updateZapRaiserAmount(null) } else { - newPostViewModel.zapRaiserAmount = it.toLongOrNull() - } - newPostViewModel.viewModelScope.launch(Dispatchers.IO) { - newPostViewModel.saveDraft() + newPostViewModel.updateZapRaiserAmount(it.toLongOrNull()) } } }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt similarity index 50% rename from app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt index 9dc3b1c44..883bc8df0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Drafts.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/DraftEventsFeedFilter.kt @@ -18,6 +18,39 @@ * 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 +package com.vitorpamplona.amethyst.ui.dal -data class Drafts(val mainId: String, val eventId: String) +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.events.DraftEvent + +class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } + + override fun applyFilter(collection: Set): Set { + return collection.filterTo(HashSet()) { + acceptableEvent(it) + } + } + + override fun feed(): List { + val drafts = + LocalCache.addressables.filterIntoSet { _, note -> + acceptableEvent(note) + } + + return sort(drafts) + } + + fun acceptableEvent(it: Note): Boolean { + val noteEvent = it.event + return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex + } + + override fun sort(collection: Set): List { + return collection.sortedWith(DefaultFeedOrder) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 69a478645..bd13a00b0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen @@ -214,6 +215,7 @@ fun AppNavigation( composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) + composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) }) Route.Profile.let { route -> composable( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index eb7e0ca7d..463e3bab7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -185,6 +185,8 @@ private fun RenderTopRouteBar( Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav) Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav) Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack) + Route.Bookmarks.base -> TopBarWithBackButton(stringResource(id = R.string.bookmarks), navPopBack) + Route.Drafts.base -> TopBarWithBackButton(stringResource(id = R.string.drafts), navPopBack) else -> { if (id != null) { when (currentRoute) { 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 7a3496f75..1e5689fb8 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,7 +88,6 @@ 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 @@ -462,12 +461,6 @@ 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 @@ -492,6 +485,15 @@ fun ListContent( route = Route.Bookmarks.route, ) + NavigationRow( + title = stringResource(R.string.drafts), + icon = Route.Drafts.icon, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + drawerState = drawerState, + route = Route.Drafts.route, + ) + IconRowRelays( accountViewModel = accountViewModel, onClick = { @@ -588,18 +590,6 @@ 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)) }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 285a3989f..0d5ccb18e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -148,6 +148,13 @@ sealed class Route( contentDescriptor = R.string.route_home, ) + object Drafts : + Route( + route = "Drafts", + icon = R.drawable.ic_topics, + contentDescriptor = R.string.drafts, + ) + object Profile : Route( route = "User/{id}", 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 7efedde72..85f8ab9ca 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, newPostViewModel = null) { showPopup -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderChannelCard( baseNote, routeForLastRead, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index 7e380fd09..8d95ec241 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.emptyLineItemModifier import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.encoders.HexKey @@ -77,6 +76,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable +import com.vitorpamplona.quartz.events.DraftEvent @Composable fun ChatroomHeaderCompose( @@ -102,12 +102,24 @@ fun ChatroomComposeChannelOrUser( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } + if (baseNote.event is DraftEvent) { + ObserveDraftEvent(baseNote, accountViewModel) { + val channelHex by remember(it) { derivedStateOf { it.channelHex() } } - if (channelHex != null) { - ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) + if (channelHex != null) { + ChatroomChannel(channelHex!!, it, accountViewModel, nav) + } else { + ChatroomPrivateMessages(it, accountViewModel, nav) + } + } } else { - ChatroomPrivateMessages(baseNote, accountViewModel, nav) + val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } } + + if (channelHex != null) { + ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav) + } else { + ChatroomPrivateMessages(baseNote, accountViewModel, nav) + } } } @@ -128,9 +140,7 @@ private fun ChatroomPrivateMessages( if (room != null) { UserRoomCompose(baseNote, room, accountViewModel, nav) } else { - Box(emptyLineItemModifier) { - // Makes sure just a max amount of objects are loaded. - } + BlankNote() } } } 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 9a195f920..0739fa6d8 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,7 +64,6 @@ 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 @@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChatMessageEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmptyTagList import com.vitorpamplona.quartz.events.ImmutableListOfLists import com.vitorpamplona.quartz.events.PrivateDmEvent @@ -103,7 +103,6 @@ fun ChatroomMessageCompose( innerQuote: Boolean = false, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -122,7 +121,6 @@ fun ChatroomMessageCompose( canPreview, parentBackgroundColor, accountViewModel, - newPostViewModel, nav, onWantsToReply, ) @@ -139,7 +137,6 @@ fun NormalChatNote( canPreview: Boolean = true, parentBackgroundColor: MutableState? = null, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -259,7 +256,6 @@ fun NormalChatNote( availableBubbleSize, showDetails, accountViewModel, - newPostViewModel, nav, ) } @@ -270,7 +266,6 @@ fun NormalChatNote( popupExpanded = popupExpanded, onDismiss = { popupExpanded = false }, accountViewModel = accountViewModel, - newPostViewModel = newPostViewModel, ) } } @@ -288,7 +283,6 @@ private fun RenderBubble( availableBubbleSize: MutableState, showDetails: State, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, ) { val bubbleSize = remember { mutableIntStateOf(0) } @@ -318,7 +312,6 @@ private fun RenderBubble( canPreview, showDetails, accountViewModel, - newPostViewModel, nav, ) } @@ -337,7 +330,6 @@ private fun MessageBubbleLines( canPreview: Boolean, showDetails: State, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, ) { if (drawAuthorInfo) { @@ -349,20 +341,22 @@ private fun MessageBubbleLines( ) } - RenderReplyRow( - note = baseNote, - innerQuote = innerQuote, - backgroundBubbleColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - newPostViewModel = newPostViewModel, - nav = nav, - onWantsToReply = onWantsToReply, - ) + if (baseNote.event !is DraftEvent) { + RenderReplyRow( + note = baseNote, + innerQuote = innerQuote, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + } NoteRow( note = baseNote, canPreview = canPreview, innerQuote = innerQuote, + onWantsToReply = onWantsToReply, backgroundBubbleColor = backgroundBubbleColor, accountViewModel = accountViewModel, nav = nav, @@ -407,12 +401,11 @@ 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, newPostViewModel, nav, onWantsToReply) + RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply) } } @@ -421,7 +414,6 @@ private fun RenderReply( note: Note, backgroundBubbleColor: MutableState, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, nav: (String) -> Unit, onWantsToReply: (Note) -> Unit, ) { @@ -440,7 +432,6 @@ private fun RenderReply( innerQuote = true, parentBackgroundColor = backgroundBubbleColor, accountViewModel = accountViewModel, - newPostViewModel = newPostViewModel, nav = nav, onWantsToReply = onWantsToReply, ) @@ -453,6 +444,7 @@ private fun NoteRow( note: Note, canPreview: Boolean, innerQuote: Boolean, + onWantsToReply: (Note) -> Unit, backgroundBubbleColor: MutableState, accountViewModel: AccountViewModel, nav: (String) -> Unit, @@ -465,6 +457,17 @@ private fun NoteRow( is ChannelMetadataEvent -> { RenderChangeChannelMetadataNote(note) } + is DraftEvent -> { + RenderDraftEvent( + note, + canPreview, + innerQuote, + onWantsToReply, + backgroundBubbleColor, + accountViewModel, + nav, + ) + } else -> { RenderRegularTextNote( note, @@ -479,6 +482,38 @@ private fun NoteRow( } } +@Composable +private fun RenderDraftEvent( + note: Note, + canPreview: Boolean, + innerQuote: Boolean, + onWantsToReply: (Note) -> Unit, + backgroundBubbleColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + ObserveDraftEvent(note, accountViewModel) { + RenderReplyRow( + note = it, + innerQuote = innerQuote, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + + NoteRow( + note = it, + canPreview = canPreview, + innerQuote = innerQuote, + onWantsToReply = onWantsToReply, + backgroundBubbleColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + @Composable private fun ConstrainedStatusRow( bubbleSize: MutableState, 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 f34c99465..609bbc80b 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -141,6 +142,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent @@ -244,7 +246,7 @@ fun AcceptableNote( nav = nav, ) else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup, -> CheckNewAndRenderNote( @@ -279,9 +281,7 @@ fun AcceptableNote( is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel) is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel) else -> - LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { - showPopup, - -> + LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup -> CheckNewAndRenderNote( baseNote = baseNote, routeForLastRead = routeForLastRead, @@ -466,7 +466,7 @@ fun InnerNoteWithReactions( } } - val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent + val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftEvent if (isNotRepost) { if (makeItShort) { @@ -483,6 +483,10 @@ fun InnerNoteWithReactions( nav = nav, ) } + } else { + if (baseNote.event is DraftEvent) { + Spacer(modifier = DoubleVertSpacer) + } } } @@ -566,6 +570,7 @@ private fun RenderNoteRow( is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav) is AudioTrackEvent -> RenderAudioTrack(baseNote, accountViewModel, nav) is AudioHeaderEvent -> RenderAudioHeader(baseNote, accountViewModel, nav) + is DraftEvent -> RenderDraft(baseNote, backgroundColor, accountViewModel, nav) is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) is GenericRepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav) @@ -682,6 +687,68 @@ private fun RenderNoteRow( } } +@Composable +fun ObserveDraftEvent( + note: Note, + accountViewModel: AccountViewModel, + render: @Composable (Note) -> Unit, +) { + val noteState by note.live().metadata.observeAsState() + + val noteEvent = noteState?.note?.event as? DraftEvent ?: return + val noteAuthor = noteState?.note?.author ?: return + + val innerNote = + produceState(initialValue = accountViewModel.createTempCachedDraftNote(noteEvent, noteAuthor), noteEvent.id) { + if (value == null || value?.event?.id() != noteEvent.id) { + accountViewModel.createTempDraftNote(noteEvent, noteAuthor) { + value = it + } + } + } + + innerNote.value?.let { + render(it) + } +} + +@Composable +fun RenderDraft( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + ObserveDraftEvent(note, accountViewModel) { + val edits = remember { mutableStateOf(GenericLoadable.Empty()) } + + ReplyRow( + it, + true, + backgroundColor, + accountViewModel, + nav, + ) + + RenderNoteRow( + baseNote = it, + backgroundColor = backgroundColor, + makeItShort = false, + canPreview = true, + editState = edits, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + + val zapSplits = remember(it.event) { it.event?.hasZapSplitSetup() } + if (zapSplits == true) { + Spacer(modifier = HalfDoubleVertSpacer) + DisplayZapSplits(it.event!!, false, accountViewModel, nav) + } + } +} + @Composable fun RenderRepost( note: Note, 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 f30e2c4fd..0f0c1638f 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,7 +40,6 @@ 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 @@ -86,7 +85,6 @@ 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 @@ -135,7 +133,6 @@ val externalLinkForNote = { note: Note -> fun LongPressToQuickAction( baseNote: Note, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, content: @Composable (() -> Unit) -> Unit, ) { val popupExpanded = remember { mutableStateOf(false) } @@ -144,7 +141,7 @@ fun LongPressToQuickAction( content(showPopup) - NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel, newPostViewModel) + NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel) } @Composable @@ -153,7 +150,6 @@ fun NoteQuickActionMenu( popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, ) { val showSelectTextDialog = remember { mutableStateOf(false) } val showDeleteAlertDialog = remember { mutableStateOf(false) } @@ -164,7 +160,6 @@ fun NoteQuickActionMenu( if (popupExpanded) { RenderMainPopup( accountViewModel, - newPostViewModel, note, onDismiss, showBlockAlertDialog, @@ -223,7 +218,6 @@ fun NoteQuickActionMenu( @Composable private fun RenderMainPopup( accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel?, note: Note, onDismiss: () -> Unit, showBlockAlertDialog: MutableState, @@ -300,6 +294,7 @@ private fun RenderMainPopup( } } + /* if (note.isDraft()) { VerticalDivider(color = primaryLight) NoteQuickActionItem( @@ -314,7 +309,7 @@ private fun RenderMainPopup( onDismiss() } } - } + }*/ if (!isOwnNote) { VerticalDivider(color = primaryLight) 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 fd24262d6..021896dea 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,7 +52,6 @@ fun WatchNoteEvent( LongPressToQuickAction( baseNote = baseNote, accountViewModel = accountViewModel, - newPostViewModel = null, ) { showPopup -> BlankNote( remember { 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 11917bbcb..e3720abd4 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 @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -175,20 +176,22 @@ class AddBountyAmountViewModel : ViewModel() { val newValue = nextAmount.text.trim().toLongOrNull() if (newValue != null) { - account?.sendPost( - message = newValue.toString(), - replyTo = listOfNotNull(bounty), - mentions = listOfNotNull(bounty?.author), - tags = listOf("bounty-added-reward"), - wantsToMarkAsSensitive = false, - replyingTo = null, - root = null, - directMentions = setOf(), - forkedFrom = null, - draftTag = null, - ) + viewModelScope.launch { + account?.sendPost( + message = newValue.toString(), + replyTo = listOfNotNull(bounty), + mentions = listOfNotNull(bounty?.author), + tags = listOf("bounty-added-reward"), + wantsToMarkAsSensitive = false, + replyingTo = null, + root = null, + directMentions = setOf(), + forkedFrom = null, + draftTag = null, + ) - nextAmount = TextFieldValue("") + nextAmount = TextFieldValue("") + } } } @@ -237,10 +240,8 @@ fun AddBountyAmountDialog( PostButton( onPost = { - scope.launch(Dispatchers.IO) { - postViewModel.sendPost() - onClose() - } + postViewModel.sendPost() + onClose() }, isActive = postViewModel.hasChanged(), ) 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 9683bb3c4..fbc0d8298 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 @@ -237,7 +237,7 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) - if (note.isDraft()) { + if (state.isLoggedUser && note.isDraft()) { DropdownMenuItem( text = { Text(stringResource(R.string.edit_draft)) }, onClick = { 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 83760f6a0..725ec7645 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,21 +38,21 @@ 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 import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.quartz.events.DraftEvent @Composable fun RefreshingChatroomFeedView( viewModel: FeedViewModel, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit, + avoidDraft: String? = null, scrollStateKey: String? = null, enablePullRefresh: Boolean = true, ) { @@ -61,11 +61,11 @@ fun RefreshingChatroomFeedView( RenderChatroomFeedView( viewModel, accountViewModel, - newPostViewModel, listState, nav, routeForLastRead, onWantsToReply, + avoidDraft, ) } } @@ -75,11 +75,11 @@ fun RefreshingChatroomFeedView( fun RenderChatroomFeedView( viewModel: FeedViewModel, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel, listState: LazyListState, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit, + avoidDraft: String? = null, ) { val feedState by viewModel.feedContent.collectAsStateWithLifecycle() @@ -95,11 +95,11 @@ fun RenderChatroomFeedView( ChatroomFeedLoaded( state, accountViewModel, - newPostViewModel, listState, nav, routeForLastRead, onWantsToReply, + avoidDraft, ) } is FeedState.Loading -> { @@ -113,11 +113,11 @@ fun RenderChatroomFeedView( fun ChatroomFeedLoaded( state: FeedState.Loaded, accountViewModel: AccountViewModel, - newPostViewModel: NewPostViewModel, listState: LazyListState, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit, + avoidDraft: String? = null, ) { LaunchedEffect(state.feed.value.firstOrNull()) { if (listState.firstVisibleItemIndex <= 1) { @@ -132,14 +132,16 @@ fun ChatroomFeedLoaded( state = listState, ) { itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - ChatroomMessageCompose( - baseNote = item, - routeForLastRead = routeForLastRead, - accountViewModel = accountViewModel, - newPostViewModel = newPostViewModel, - nav = nav, - onWantsToReply = onWantsToReply, - ) + val noteEvent = item.event + if (avoidDraft == null || noteEvent !is DraftEvent || noteEvent.dTag() != avoidDraft) { + ChatroomMessageCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply, + ) + } NewSubject(item) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index b11e369ba..7378ecf59 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DraftEventsFeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter @@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter import com.vitorpamplona.quartz.events.ChatroomKey +import com.vitorpamplona.quartz.events.DeletionEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -267,6 +269,16 @@ class NostrBookmarkPrivateFeedViewModel(val account: Account) : } } +@Stable +class NostrDraftEventsFeedViewModel(val account: Account) : + FeedViewModel(DraftEventsFeedFilter(account)) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrDraftEventsFeedViewModel { + return NostrDraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel + } + } +} + class NostrUserAppRecommendationsFeedViewModel(val user: User) : FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) { class Factory(val user: User) : ViewModelProvider.Factory { @@ -344,9 +356,24 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : val oldNotesState = _feedContent.value if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) { if (oldNotesState is FeedState.Loaded) { + val deletionEvents: List = + newItems.mapNotNull { + val noteEvent = it.event + if (noteEvent is DeletionEvent) noteEvent else null + } + + val oldList = + if (deletionEvents.isEmpty()) { + oldNotesState.feed.value + } else { + val deletedEventIds = deletionEvents.flatMapTo(HashSet()) { it.deleteEvents() } + val deletedEventAddresses = deletionEvents.flatMapTo(HashSet()) { it.deleteAddresses() } + oldNotesState.feed.value.filter { !it.wasOrShouldBeDeletedBy(deletedEventIds, deletedEventAddresses) }.toImmutableList() + } + val newList = localFilter - .updateListWith(oldNotesState.feed.value, newItems) + .updateListWith(oldList, newItems) .distinctBy { it.idHex } .toImmutableList() if (!equalImmutableLists(newList, oldNotesState.feed.value)) { 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 3e427b4a0..94f075b38 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 @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow +import com.vitorpamplona.amethyst.ui.note.RenderDraft import com.vitorpamplona.amethyst.ui.note.RenderRepost import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus @@ -155,6 +156,7 @@ import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.FhirResourceEvent @@ -540,6 +542,8 @@ fun NoteMaster( RenderGitIssueEvent(baseNote, false, true, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) + } else if (noteEvent is DraftEvent) { + RenderDraft(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { DisplayHighlight( noteEvent.quote(), @@ -610,7 +614,7 @@ fun NoteMaster( ReactionsRow(note, true, editState, accountViewModel, nav) } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel, null) + NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) } } 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 d4d03478d..3ea6d101a 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 @@ -71,8 +71,10 @@ import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.Nip11RelayInformation import com.vitorpamplona.quartz.encoders.Nip19Bech32 +import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable +import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.GiftWrapEvent @@ -573,6 +575,10 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View return account.cachedDecryptContent(note) } + fun cachedDecrypt(event: EventInterface?): String? { + return account.cachedDecryptContent(event) + } + fun decrypt( note: Note, onReady: (String) -> Unit, @@ -1313,8 +1319,40 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } suspend fun deleteDraft(draftTag: String) { - val notes = LocalCache.draftNotes(draftTag) - account.delete(notes) + account.deleteDraft(draftTag) + } + + fun createTempCachedDraftNote( + noteEvent: DraftEvent, + author: User, + ): Note? { + return noteEvent.preCachedDraft(account.signer)?.let { createTempDraftNote(it, author) } + } + + fun createTempDraftNote( + noteEvent: DraftEvent, + author: User, + onReady: (Note) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + noteEvent.cachedDraft(account.signer) { + onReady(createTempDraftNote(it, author)) + } + } + } + + fun createTempDraftNote( + innerEvent: Event, + author: User, + ): Note { + val note = + if (innerEvent is AddressableEvent) { + AddressableNote(innerEvent.address()) + } else { + Note(innerEvent.id) + } + note.loadEvent(innerEvent, author, LocalCache.computeReplyTo(innerEvent)) + return note } val bechLinkCache = CachedLoadedBechLink(this) 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 c48b01841..1828e396d 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 @@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -210,21 +211,8 @@ 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, @@ -306,56 +294,34 @@ fun ChannelScreen( RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, - newPostViewModel = newPostModel, nav = nav, routeForLastRead = "Channel/${channel.idHex}", + avoidDraft = newPostModel.draftTag, onWantsToReply = { replyTo.value = it }, ) } Spacer(modifier = DoubleVertSpacer) - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } } + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + newPostModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + innerSendPost(replyTo, channel, newPostModel, accountViewModel, newPostModel.draftTag) + } + } + } + // LAST ROW EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) { scope.launch(Dispatchers.IO) { - val tagger = - NewMessageTagger( - message = newPostModel.message.text, - pTags = listOfNotNull(replyTo.value?.author), - eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, - dao = accountViewModel, - ) - tagger.run() - - val urls = findURLs(tagger.message) - val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } - - if (channel is PublicChatChannel) { - accountViewModel.account.sendChannelMessage( - message = tagger.message, - toChannel = channel.idHex, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, - draftTag = null, - ) - } else if (channel is LiveActivitiesChannel) { - accountViewModel.account.sendLiveMessage( - message = tagger.message, - toChannel = channel.address, - replyTo = tagger.eTags, - mentions = tagger.pTags, - wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, - draftTag = null, - ) - } + innerSendPost(replyTo, channel, newPostModel, accountViewModel, null) newPostModel.message = TextFieldValue("") replyTo.value = null accountViewModel.deleteDraft(newPostModel.draftTag) @@ -366,11 +332,53 @@ fun ChannelScreen( } } +private suspend fun innerSendPost( + replyTo: MutableState, + channel: Channel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + draftTag: String?, +) { + val tagger = + NewMessageTagger( + message = newPostModel.message.text, + pTags = listOfNotNull(replyTo.value?.author), + eTags = listOfNotNull(replyTo.value), + channelHex = channel.idHex, + dao = accountViewModel, + ) + tagger.run() + + val urls = findURLs(tagger.message) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + + if (channel is PublicChatChannel) { + accountViewModel.account.sendChannelMessage( + message = tagger.message, + toChannel = channel.idHex, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, + draftTag = draftTag, + ) + } else if (channel is LiveActivitiesChannel) { + accountViewModel.account.sendLiveMessage( + message = tagger.message, + toChannel = channel.address, + replyTo = tagger.eTags, + mentions = tagger.pTags, + wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, + draftTag = draftTag, + ) + } +} + @Composable fun DisplayReplyingToNote( replyingNote: Note?, accountViewModel: AccountViewModel, - newPostModel: NewPostViewModel, nav: (String) -> Unit, onCancel: () -> Unit, ) { @@ -389,7 +397,6 @@ fun DisplayReplyingToNote( null, innerQuote = true, accountViewModel = accountViewModel, - newPostViewModel = newPostModel, nav = nav, onWantsToReply = {}, ) 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 b9b1c6d83..8eae312ca 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 @@ -57,6 +57,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -116,8 +117,6 @@ 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 @@ -238,20 +237,8 @@ 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 { @@ -333,54 +320,42 @@ fun ChatroomScreen( RefreshingChatroomFeedView( viewModel = feedViewModel, accountViewModel = accountViewModel, - newPostViewModel = newPostModel, nav = nav, routeForLastRead = "Room/${room.hashCode()}", + avoidDraft = newPostModel.draftTag, onWantsToReply = { replyTo.value = it - newPostModel.originalNote = it }, ) } Spacer(modifier = Modifier.height(10.dp)) - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } } + replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } val scope = rememberCoroutineScope() + LaunchedEffect(key1 = newPostModel.draftTag) { + launch(Dispatchers.IO) { + newPostModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag) + } + } + } + // LAST ROW PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) { scope.launch(Dispatchers.IO) { + innerSendPost(newPostModel, room, replyTo, accountViewModel, null) + 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() } - - if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { - accountViewModel.account.sendNIP24PrivateMessage( - message = newPostModel.message.text, - toUsers = room.users.toList(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, - draftTag = null, - ) - } else { - accountViewModel.account.sendPrivateMessage( - message = newPostModel.message.text, - toUser = room.users.first(), - replyingTo = replyTo.value, - mentions = null, - wantsToMarkAsSensitive = false, - nip94attachments = usedAttachments, - draftTag = null, - ) - } newPostModel.message = TextFieldValue("") + newPostModel.draftTag = UUID.randomUUID().toString() + replyTo.value = null feedViewModel.sendToTop() } @@ -388,6 +363,39 @@ fun ChatroomScreen( } } +private fun innerSendPost( + newPostModel: NewPostViewModel, + room: ChatroomKey, + replyTo: MutableState, + accountViewModel: AccountViewModel, + dTag: String?, +) { + val urls = findURLs(newPostModel.message.text) + val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() } + + if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) { + accountViewModel.account.sendNIP24PrivateMessage( + message = newPostModel.message.text, + toUsers = room.users.toList(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, + draftTag = dTag, + ) + } else { + accountViewModel.account.sendPrivateMessage( + message = newPostModel.message.text, + toUser = room.users.first(), + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false, + nip94attachments = usedAttachments, + draftTag = dTag, + ) + } +} + @Composable fun PrivateMessageEditFieldRow( channelScreenModel: NewPostViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DraftListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DraftListScreen.kt new file mode 100644 index 000000000..492ec612e --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DraftListScreen.kt @@ -0,0 +1,82 @@ +/** + * 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.ui.screen.loggedIn + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.ui.screen.NostrDraftEventsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView + +@Composable +fun DraftListScreen( + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val draftFeedViewModel: NostrDraftEventsFeedViewModel = + viewModel( + key = "NostrDraftEventsFeedViewModel", + factory = NostrDraftEventsFeedViewModel.Factory(accountViewModel.account), + ) + + RenderDraftListScreen(draftFeedViewModel, accountViewModel, nav) +} + +@Composable +private fun RenderDraftListScreen( + feedViewModel: NostrDraftEventsFeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(feedViewModel) { + feedViewModel.invalidateData() + } + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("DraftList Start") + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("DraftList Stop") + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fde9df12d..b05f069b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -293,6 +293,7 @@ Block Bookmarks + Drafts Private Bookmarks Public Bookmarks Add to Private Bookmarks diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 54ec15353..f16b43892 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -68,6 +68,7 @@ class GiftWrapReceivingBenchmark { markAsSensitive = true, zapRaiserAmount = 10000, geohash = null, + isDraft = true, signer = sender, ) { SealedGossipEvent.create( @@ -107,6 +108,7 @@ class GiftWrapReceivingBenchmark { markAsSensitive = true, zapRaiserAmount = 10000, geohash = null, + isDraft = true, signer = sender, ) { SealedGossipEvent.create( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt index 742ca64d7..4543042bc 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.events import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.ATag import com.vitorpamplona.quartz.encoders.HexKey -import com.vitorpamplona.quartz.encoders.Nip19Bech32 import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -35,126 +35,171 @@ class DraftEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Transient private var decryptedContent: Map = mapOf() + @Transient private var cachedInnerEvent: Map = mapOf() - @Transient private var citedNotesCache: Set? = null + override fun isContentEncoded() = true - 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) + fun isDeleted() = content == "" - val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot) - - return if (newStyleReplyTos.isNotEmpty()) { - newStyleReplyTos - } else { - oldStylePositional - } + fun preCachedDraft(signer: NostrSigner): Event? { + return cachedInnerEvent[signer.pubKey] } - fun findCitations(): Set { - citedNotesCache?.let { - return it - } + fun allCache() = cachedInnerEvent.values - 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 addToCache( + pubKey: HexKey, + innerEvent: Event, + ) { + cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent) } - 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( + fun cachedDraft( signer: NostrSigner, onReady: (Event) -> Unit, ) { - decryptedContent[dTag()]?.let { + cachedInnerEvent[signer.pubKey]?.let { onReady(it) return } + decrypt(signer) { draft -> + addToCache(signer.pubKey, draft) - signer.nip44Decrypt(content, signer.pubKey) { retVal -> - val event = runCatching { fromJson(retVal) }.getOrNull() ?: return@nip44Decrypt - decryptedContent = decryptedContent + Pair(dTag(), event) + onReady(draft) + } + } - onReady(event) + private fun decrypt( + signer: NostrSigner, + onReady: (Event) -> Unit, + ) { + try { + plainContent(signer) { onReady(fromJson(it)) } + } catch (e: Exception) { + // Log.e("UnwrapError", "Couldn't Decrypt the content", e) + } + } + + private fun plainContent( + signer: NostrSigner, + onReady: (String) -> Unit, + ) { + if (content.isEmpty()) return + + signer.nip44Decrypt(content, pubKey, onReady) + } + + fun createDeletedEvent( + signer: NostrSigner, + onReady: (DraftEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, tags, "") { + onReady(it) } } companion object { const val KIND = 31234 + fun createAddressTag( + pubKey: HexKey, + dTag: String, + ): String { + return ATag(KIND, pubKey, dTag, null).toTag() + } + fun create( dTag: String, - originalNote: EventInterface, + originalNote: LiveActivitiesChatMessageEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag())) } + originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } + + create(dTag, originalNote, emptyList(), signer, createdAt, onReady) + } + + fun create( + dTag: String, + originalNote: ChannelMessageEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + originalNote.channel()?.let { tags.add(arrayOf("e", it)) } + + create(dTag, originalNote, tags, signer, createdAt, onReady) + } + + fun create( + dTag: String, + originalNote: GitReplyEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tags = mutableListOf>() + originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) } + originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } + + create(dTag, originalNote, tags, signer, createdAt, onReady) + } + + fun create( + dTag: String, + originalNote: PollNoteEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tagsWithMarkers = + originalNote.tags().filter { + it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") + } + + create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + } + + fun create( + dTag: String, + originalNote: TextNoteEvent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + val tagsWithMarkers = + originalNote.tags().filter { + it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply") + } + + create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady) + } + + fun create( + dTag: String, + innerEvent: Event, + anchorTagArray: List> = emptyList(), 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" }) + tags.add(arrayOf("k", "${innerEvent.kind}")) - signer.nip44Encrypt(originalNote.toJson(), signer.pubKey) { encryptedContent -> - signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent, onReady) + if (anchorTagArray.isNotEmpty()) { + tags.addAll(anchorTagArray) + } + + signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey) { encryptedContent -> + signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent) { + it.addToCache(signer.pubKey, innerEvent) + onReady(it) + } } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index f0f35f3a4..ab0ff1fd7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -97,6 +97,8 @@ open class Event( override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } + override fun firstTaggedK() = tags.firstOrNull { it.size > 1 && it[0] == "k" }?.let { it[1].toIntOrNull() } + override fun firstTaggedAddress() = tags .firstOrNull { it.size > 1 && it[0] == "a" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index 2bcccba78..0c5ce06f7 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -133,6 +133,8 @@ interface EventInterface { fun firstTaggedUrl(): String? + fun firstTaggedK(): Int? + fun taggedEmojis(): List fun matchTag1With(text: String): Boolean