From c087c5017c517990ef87422f82caf11447c0a0dd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Feb 2023 12:02:07 -0500 Subject: [PATCH] NIP-09 Event Deletion support: Works with TextNotes, Likes, Boosts and Reports. --- .../vitorpamplona/amethyst/model/Account.kt | 44 ++++++++++++++--- .../amethyst/model/LocalCache.kt | 48 +++++++++++++++++-- .../com/vitorpamplona/amethyst/model/Note.kt | 25 ++++++++-- .../com/vitorpamplona/amethyst/model/User.kt | 15 ++++++ .../amethyst/service/NostrDataSource.kt | 2 +- .../service/NostrUserProfileDataSource.kt | 2 +- .../amethyst/ui/note/NoteCompose.kt | 8 +++- .../amethyst/ui/note/ReactionsRow.kt | 22 ++++++--- .../ui/screen/loggedIn/AccountViewModel.kt | 24 ++++++++++ 9 files changed, 168 insertions(+), 22 deletions(-) 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 16ff25321..6dff049ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -30,6 +30,7 @@ import nostr.postr.Contact import nostr.postr.Persona import nostr.postr.Utils import nostr.postr.events.ContactListEvent +import nostr.postr.events.DeletionEvent import nostr.postr.events.Event import nostr.postr.events.MetadataEvent import nostr.postr.events.PrivateDmEvent @@ -123,10 +124,26 @@ class Account( } } + fun reactionTo(note: Note): List { + return note.reactedBy(userProfile(), "+") + } + + fun hasBoosted(note: Note): Boolean { + return boostsTo(note).isNotEmpty() + } + + fun boostsTo(note: Note): List { + return note.boostedBy(userProfile()) + } + + fun hasReacted(note: Note): Boolean { + return note.hasReacted(userProfile(), "+") + } + fun reactTo(note: Note) { if (!isWriteable()) return - if (note.hasReacted(userProfile(), "+")) { + if (hasReacted(note)) { // has already liked this note return } @@ -151,6 +168,7 @@ class Account( fun createZapRequestFor(user: User): LnZapRequestEvent? { return createZapRequestFor(user.pubkeyHex) } + fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? { if (!isWriteable()) return null @@ -174,7 +192,7 @@ class Account( note.event?.let { val event = ReportEvent.create(it, type, loggedIn.privKey!!) Client.send(event) - LocalCache.consume(event) + LocalCache.consume(event, null) } } @@ -188,13 +206,29 @@ class Account( val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!) Client.send(event) - LocalCache.consume(event) + LocalCache.consume(event, null) + } + + fun delete(note: Note) { + delete(listOf(note)) + } + + fun delete(notes: List) { + if (!isWriteable()) return + + val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex } + + if (myNotes.isNotEmpty()) { + val event = DeletionEvent.create(myNotes, loggedIn.privKey!!) + Client.send(event) + LocalCache.consume(event) + } } fun boost(note: Note) { if (!isWriteable()) return - if (note.hasBoosted(userProfile())) { + if (note.hasBoostedInTheLast5Minutes(userProfile())) { // has already bosted in the past 5mins return } @@ -532,8 +566,6 @@ class Account( saveable.invalidateData() } - - init { backupContactList?.let { println("Loading saved contacts ${it.toJson()}") 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 e1a6f0add..7d040a53e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -281,7 +281,41 @@ object LocalCache { } fun consume(event: DeletionEvent) { - //Log.d("DEL", event.toJson()) + var deletedAtLeastOne = false + + event.deleteEvents.mapNotNull { notes[it] }.forEach { deleteNote -> + // must be the same author + if (deleteNote.author?.pubkeyHex == event.pubKey.toHexKey()) { + deleteNote.author?.removeNote(deleteNote) + + // reverts the add + deleteNote.mentions?.forEach { user -> + user.removeTaggedPost(deleteNote) + user.removeReport(deleteNote) + } + + deleteNote.replyTo?.forEach { replyingNote -> + replyingNote.author?.removeTaggedPost(deleteNote) + } + + // Counts the replies + deleteNote.replyTo?.forEach { masterNote -> + masterNote.removeReply(deleteNote) + masterNote.removeBoost(deleteNote) + masterNote.removeReaction(deleteNote) + masterNote.removeZap(deleteNote) + masterNote.removeReport(deleteNote) + } + + notes.remove(deleteNote.idHex) + + deletedAtLeastOne = true + } + } + + if (deletedAtLeastOne) { + live.invalidateData() + } } fun consume(event: RepostEvent) { @@ -362,13 +396,18 @@ object LocalCache { } } - fun consume(event: ReportEvent) { + fun consume(event: ReportEvent, relay: Relay?) { val note = getOrCreateNote(event.id.toHex()) + val author = getOrCreateUser(event.pubKey.toHexKey()) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } // Already processed this event. if (note.event != null) return - val author = getOrCreateUser(event.pubKey.toHexKey()) val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) } val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it) } @@ -658,6 +697,8 @@ object LocalCache { } toBeRemoved.forEach { + it.author?.removeNote(it) + // reverts the add it.mentions?.forEach { user -> user.removeTaggedPost(it) @@ -672,6 +713,7 @@ object LocalCache { masterNote.removeBoost(it) masterNote.removeReaction(it) masterNote.removeZap(it) + masterNote.removeReport(it) } notes.remove(it.idHex) 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 e0cc9681f..5853a3fef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -21,7 +21,6 @@ import java.util.Date import java.util.concurrent.atomic.AtomicBoolean import java.util.regex.Pattern import nostr.postr.events.Event -import nostr.postr.toNpub val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") @@ -116,6 +115,18 @@ class Note(val idHex: String) { reactions = reactions - note liveSet?.reactions?.invalidateData() } + + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + liveSet?.reports?.invalidateData() + } + } + } + fun removeZap(note: Note) { if (zaps[note] != null) { zaps = zaps.minus(note) @@ -248,14 +259,22 @@ class Note(val idHex: String) { } fun hasReacted(loggedIn: User, content: String): Boolean { - return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null + return reactedBy(loggedIn, content).isNotEmpty() } - fun hasBoosted(loggedIn: User): Boolean { + fun reactedBy(loggedIn: User, content: String): List { + return reactions.filter { it.author == loggedIn && it.event?.content == content } + } + + fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { val currentTime = Date().time / 1000 return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection } + fun boostedBy(loggedIn: User): List { + return boosts.filter { it.author == loggedIn } + } + var liveSet: NoteLiveSet? = null fun live(): NoteLiveSet { 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 4abc19a8c..2c39050dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -134,6 +134,10 @@ class User(val pubkeyHex: String) { } } + fun removeNote(note: Note) { + notes = notes - note + } + fun clearNotes() { notes = setOf() } @@ -155,6 +159,17 @@ class User(val pubkeyHex: String) { } } + fun removeReport(deleteNote: Note) { + val author = deleteNote.author ?: return + + if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) { + reports[author]?.let { + reports = reports + Pair(author, it.minus(deleteNote)) + liveSet?.reports?.invalidateData() + } + } + } + fun addZap(zapRequest: Note, zap: Note?) { if (zapRequest !in zaps.keys) { zaps = zaps + Pair(zapRequest, zap) 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 044c36940..a88426d40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -74,7 +74,7 @@ abstract class NostrDataSource(val debugName: String) { LocalCache.consume(repostEvent) } ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) - ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) + ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay) LnZapEvent.kind -> { val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 6f937fc0e..5d7c14ee1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -42,7 +42,7 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") { filter = JsonFilter( kinds = listOf(TextNoteEvent.kind), authors = listOf(it.pubkeyHex), - limit = 100 + limit = 200 ) ) } 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 76378d4d9..a061ae22f 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 @@ -536,6 +536,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { Text("Broadcast") } + if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) { + Divider() + DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) { + Text("Request Deletion") + } + } if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) { Divider() DropdownMenuItem(onClick = { @@ -546,7 +552,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, ) }; onDismiss() }) { - Text("Block & Hide User") + Text("Block & Hide Author") } Divider() DropdownMenuItem(onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index ec42c1aab..400b76bfd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -192,9 +192,13 @@ private fun BoostReaction( IconButton( modifier = Modifier.then(Modifier.size(20.dp)), onClick = { - if (accountViewModel.isWriteable()) - wantsToBoost = true - else + if (accountViewModel.isWriteable()) { + if (accountViewModel.hasBoosted(baseNote)) { + accountViewModel.deleteBoostsTo(baseNote) + } else { + wantsToBoost = true + } + } else scope.launch { Toast.makeText( context, @@ -250,7 +254,7 @@ fun LikeReaction( textModifier: Modifier = Modifier ) { val reactionsState by baseNote.live().reactions.observeAsState() - val reactedNote = reactionsState?.note + val reactedNote = reactionsState?.note ?: return val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) val context = LocalContext.current @@ -259,9 +263,13 @@ fun LikeReaction( IconButton( modifier = Modifier.then(Modifier.size(20.dp)), onClick = { - if (accountViewModel.isWriteable()) - accountViewModel.reactTo(baseNote) - else + if (accountViewModel.isWriteable()) { + if (accountViewModel.hasReactedTo(baseNote)) { + accountViewModel.deleteReactionTo(baseNote) + } else { + accountViewModel.reactTo(baseNote) + } + } else scope.launch { Toast.makeText( context, 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 13167aeb2..e9dbb43cb 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 @@ -34,6 +34,22 @@ class AccountViewModel(private val account: Account): ViewModel() { account.reactTo(note) } + fun hasReactedTo(baseNote: Note): Boolean { + return account.hasReacted(baseNote) + } + + fun deleteReactionTo(note: Note) { + account.delete(account.reactionTo(note)) + } + + fun hasBoosted(baseNote: Note): Boolean { + return account.hasBoosted(baseNote) + } + + fun deleteBoostsTo(note: Note) { + account.delete(account.boostsTo(note)) + } + fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() @@ -71,6 +87,10 @@ class AccountViewModel(private val account: Account): ViewModel() { account.broadcast(note) } + fun delete(note: Note) { + account.delete(note) + } + fun decrypt(note: Note): String? { return account.decryptContent(note) } @@ -94,4 +114,8 @@ class AccountViewModel(private val account: Account): ViewModel() { fun prefer(source: String, target: String, preference: String) { account.prefer(source, target, preference) } + + + + } \ No newline at end of file