Activating NIP 56 (Report Users and Posts with Event Kind 1984)

This commit is contained in:
Vitor Pamplona 2023-01-29 22:06:48 -03:00
parent 9fe73c7a97
commit 8b1e0f9af0
14 changed files with 260 additions and 28 deletions

View File

@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
@ -102,7 +103,7 @@ class Account(
}
}
fun report(note: Note) {
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (
@ -117,6 +118,27 @@ class Account(
Client.send(event)
LocalCache.consume(event)
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun report(user: User, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (
user.reports.firstOrNull { it.author == userProfile() && it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type) } != null
) {
// has already reported this note
return
}
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
fun boost(note: Note) {
@ -347,9 +369,12 @@ class Account(
}
}
fun isHidden(user: User) = user !in hiddenUsers()
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
&& user.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post
&& user.reports.filter { it.author in userProfile().follows }.size < 5
}
fun isAcceptableDirect(note: Note): Boolean {
@ -364,6 +389,7 @@ class Account(
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } != null)
) // is not a reaction about a blocked post
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {

View File

@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.io.ByteArrayInputStream
import java.time.Instant
@ -273,6 +274,28 @@ object LocalCache {
}
}
fun consume(event: ReportEvent) {
val note = getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.reportedAuthor.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }.toMutableList()
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addReport(note)
}
repliesTo.forEach {
it.addReport(note)
}
}
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event

View File

@ -35,7 +35,6 @@ class Note(val idHex: String) {
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var channel: Channel? = null

View File

@ -38,6 +38,8 @@ class User(val pubkey: ByteArray) {
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
}
@ -83,6 +85,13 @@ class User(val pubkey: ByteArray) {
updateSubscribers { it.onNewPosts() }
}
fun addReport(note: Note) {
if (reports.add(note)) {
updateSubscribers { it.onNewReports() }
invalidateData()
}
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
return messages[user] ?: run {
@ -169,6 +178,7 @@ class User(val pubkey: ByteArray) {
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewMessage() = Unit
open fun onNewReports() = Unit
}
// Refreshes observers in batches.

View File

@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
@ -66,6 +67,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
else -> when (event.kind) {
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
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))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))

View File

@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
@ -24,7 +25,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to reactionsToWatch)
)

View File

@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
@ -22,7 +23,19 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
}
}
fun createUserReportFilter(): List<JsonFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
)
}
}
val userChannel = requestNewChannel()
val userReportChannel = requestNewChannel()
override fun feed(): List<User> {
return synchronized(usersToWatch) {
@ -34,6 +47,7 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
override fun updateChannelFilters() {
userChannel.filter = createUserFilter()
userReportChannel.filter = createUserReportFilter()
}
fun add(userId: String) {

View File

@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.ui.text.toUpperCase
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
// NIP 56 event.
class ReportEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 1984
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
}
enum class ReportType() {
EXPLICIT,
ILLEGAL,
SPAM,
IMPERSONATION
}
}

View File

@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
@ -46,6 +47,7 @@ fun isValidURL(url: String?): Boolean {
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
Column(modifier = Modifier.padding(top = 5.dp)) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->

View File

@ -20,6 +20,7 @@ import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@ -38,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@ -51,6 +53,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@ -74,6 +77,9 @@ fun NoteCompose(
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
var popupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
@ -83,7 +89,7 @@ fun NoteCompose(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (account?.isAcceptable(note) == false) {
} else if (!account.isAcceptable(noteForReports)) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
@ -239,8 +245,20 @@ fun NoteCompose(
}
} else {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, navController)
if (eventContent != null) {
if (note.reports.size > 0) {
// Doesn't load images
Row() {
Text(
text = eventContent,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
RichTextViewer(eventContent, note.event?.tags, navController)
}
}
//if (note.event !is ChannelMessageEvent) {
ReactionsRow(note, accountViewModel)
@ -364,7 +382,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Copy Text")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
Text("Copy User PubKey")
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); onDismiss() }) {
Text("Copy Note ID")
@ -374,11 +392,37 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text("Broadcast")
}
Divider()
DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) {
Text("Report Post")
}
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}

View File

@ -68,7 +68,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account?.isAcceptable(user) == false) {
if (account?.isHidden(user) == false) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)

View File

@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -39,6 +40,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
@ -157,11 +159,16 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
val noteState by baseNote.live.observeAsState()
val note = noteState?.note
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
if (note?.event == null) {
BlankNote()
} else if (!account.isAcceptable(noteForReports)) {
HiddenNote()
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user

View File

@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
@ -17,8 +18,12 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.reactTo(note)
}
fun report(note: Note) {
account.report(note)
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
}
fun report(user: User, type: ReportEvent.ReportType) {
account.report(user, type)
}
fun boost(note: Note) {

View File

@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.note.UserPicture
@ -266,7 +267,7 @@ private fun ProfileHeader(
if (accountUser == user) {
EditButton(account)
} else {
if (!account.isAcceptable(user)) {
if (!account.isHidden(user)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
@ -507,21 +508,53 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub() ?: "")); onDismiss() }) {
Text("Copy User ID")
}
Divider()
if (!account.isAcceptable(user)) {
DropdownMenuItem(onClick = {
user.let {
accountViewModel.show(
it,
context
)
}; onDismiss()
}) {
Text("Unblock User")
if ( account.userProfile() != user) {
Divider()
if (!account.isHidden(user)) {
DropdownMenuItem(onClick = {
user.let {
accountViewModel.show(
it,
context
)
}; onDismiss()
}) {
Text("Unblock User")
}
} else {
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
}
}
} else {
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Block User")
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.SPAM);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.EXPLICIT);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL);
user.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}