Support for Replaceable Events (NIP-33)

This commit is contained in:
Vitor Pamplona
2023-03-03 11:35:29 -05:00
parent 83f46f1a66
commit f4d5785710
43 changed files with 533 additions and 371 deletions

View File

@@ -27,6 +27,8 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] URI Support (NIP-21) - [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports) - [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05) - [x] Identity Verification (NIP-05)
- [x] Long-form Content (NIP-23)
- [x] Parameterized Replaceable Events (NIP-33)
- [ ] Local Database - [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
@@ -36,7 +38,6 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [ ] Generic Tags (NIP-12) - [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14) - [ ] Events with a Subject (NIP-14)
- [ ] Long-form Content (NIP-23)
- [ ] Online Relay Search (NIP-50) - [ ] Online Relay Search (NIP-50)
- [ ] Workspaces - [ ] Workspaces
- [ ] Expiration Support (NIP-40) - [ ] Expiration Support (NIP-40)

View File

@@ -290,13 +290,13 @@ class Account(
val repliesToHex = replyTo?.map { it.idHex } val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex } val mentionsHex = mentions?.map { it.pubkeyHex }
val addressesHex = replyTo?.mapNotNull { it.address() } val addresses = replyTo?.mapNotNull { it.address() }
val signedEvent = TextNoteEvent.create( val signedEvent = TextNoteEvent.create(
msg = message, msg = message,
replyTos = repliesToHex, replyTos = repliesToHex,
mentions = mentionsHex, mentions = mentionsHex,
addresses = addressesHex, addresses = addresses,
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
) )
Client.send(signedEvent) Client.send(signedEvent)

View File

@@ -51,7 +51,7 @@ class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> { fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false } .filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.event?.createdAt } .sortedBy { it.createdAt() }
.reversed() .reversed()
.take(1000) .take(1000)
.toSet() .toSet()

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -52,6 +53,7 @@ object LocalCache {
val users = ConcurrentHashMap<HexKey, User>() val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>() val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>() val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
fun checkGetOrCreateUser(key: String): User? { fun checkGetOrCreateUser(key: String): User? {
return try { return try {
@@ -111,6 +113,29 @@ object LocalCache {
} }
} }
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
return try {
val addr = ATag.parse(key)
if (addr != null)
getOrCreateAddressableNote(addr)
else
null
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
return addressables[key.toNAddr()] ?: run {
val answer = AddressableNote(key)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toNAddr(), answer)
answer
}
}
fun consume(event: MetadataEvent) { fun consume(event: MetadataEvent) {
// new event // new event
@@ -159,8 +184,9 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, replyTo) note.loadEvent(event, author, mentions, replyTo)
@@ -193,7 +219,7 @@ object LocalCache {
return return
} }
val note = getOrCreateNote(event.id.toHex()) val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) { if (relay != null) {
@@ -202,17 +228,15 @@ object LocalCache {
} }
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event?.id?.toHex() == event.id.toHex()) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, mentions, replyTo) note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}") author.addNote(note)
// Prepares user's profile view.
author.addLongFormNote(note)
// Adds notifications to users. // Adds notifications to users.
mentions.forEach { mentions.forEach {
@@ -224,6 +248,7 @@ object LocalCache {
refreshObservers() refreshObservers()
} }
}
private fun findCitations(event: Event): Set<String> { private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>() var citations = mutableSetOf<String>()
@@ -245,13 +270,13 @@ object LocalCache {
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> { private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
val citations = findCitations(event) val citations = findCitations(event)
return event.replyTos.filter { it !in citations } return event.replyTos().filter { it !in citations }
} }
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> { private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
val citations = findCitations(event) val citations = findCitations(event)
return event.replyTos.filter { it !in citations } return event.replyTos().filter { it !in citations }
} }
fun consume(event: RecommendRelayEvent) { fun consume(event: RecommendRelayEvent) {
@@ -378,8 +403,9 @@ object LocalCache {
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") //Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost.mapNotNull { checkGetOrCreateNote(it) } val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo) note.loadEvent(event, author, mentions, repliesTo)
@@ -409,8 +435,9 @@ object LocalCache {
if (note.event != null) return if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost.mapNotNull { checkGetOrCreateNote(it) } val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo) note.loadEvent(event, author, mentions, repliesTo)
@@ -459,8 +486,9 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it.key) } val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it.key) } val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo) note.loadEvent(event, author, mentions, repliesTo)
@@ -483,7 +511,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) { if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt) oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id.toHex()) val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note) oldChannel.addNote(note)
@@ -496,15 +524,16 @@ object LocalCache {
} }
} }
fun consume(event: ChannelMetadataEvent) { fun consume(event: ChannelMetadataEvent) {
val channelId = event.channel()
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
if (event.channel.isNullOrBlank()) return if (channelId.isNullOrBlank()) return
// new event // new event
val oldChannel = checkGetOrCreateChannel(event.channel) ?: return val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) { if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt) oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id.toHex()) val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note) oldChannel.addNote(note)
@@ -518,7 +547,9 @@ object LocalCache {
} }
fun consume(event: ChannelMessageEvent, relay: Relay?) { fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return val channelId = event.channel()
if (channelId.isNullOrBlank()) return
if (antiSpam.isSpam(event)) { if (antiSpam.isSpam(event)) {
relay?.let { relay?.let {
it.spamCounter++ it.spamCounter++
@@ -526,7 +557,7 @@ object LocalCache {
return return
} }
val channel = checkGetOrCreateChannel(event.channel) ?: return val channel = checkGetOrCreateChannel(channelId) ?: return
val note = getOrCreateNote(event.id.toHex()) val note = getOrCreateNote(event.id.toHex())
channel.addNote(note) channel.addNote(note)
@@ -541,8 +572,8 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) } .mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent } .filter { it.event !is ChannelCreateEvent }
@@ -580,13 +611,16 @@ object LocalCache {
// Already processed this event. // Already processed this event.
if (note.event != null) return if (note.event != null) return
val zapRequest = event.containedPost()?.id?.toHexKey()?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) } val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
note.loadEvent(event, author, mentions, repliesTo) note.loadEvent(event, author, mentions, repliesTo)
val zapRequest = event.containedPost?.id?.toHexKey()?.let { getOrCreateNote(it) }
if (zapRequest == null) { if (zapRequest == null) {
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}") Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
return return
@@ -617,8 +651,9 @@ object LocalCache {
if (note.event != null) return if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey()) val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) } val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) } val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo) note.loadEvent(event, author, mentions, repliesTo)
@@ -652,9 +687,13 @@ object LocalCache {
return notes.values.filter { return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false) (it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false) || (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|| (it.event is LongTextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|| it.idHex.startsWith(text, true) || it.idHex.startsWith(text, true)
|| it.idNote().startsWith(text, true) || it.idNote().startsWith(text, true)
} + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false
|| (it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false
|| (it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false
|| it.idHex.startsWith(text, true)
} }
} }
@@ -738,7 +777,7 @@ object LocalCache {
fun pruneHiddenMessages(account: Account) { fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map { val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet()) + (users[it]?.longFormNotes?.values?.flatten() ?: emptySet()) (users[it]?.notes ?: emptySet())
}.flatten() }.flatten()
account.hiddenUsers.forEach { account.hiddenUsers.forEach {
@@ -747,7 +786,6 @@ object LocalCache {
toBeRemoved.forEach { toBeRemoved.forEach {
it.author?.removeNote(it) it.author?.removeNote(it)
it.author?.removeLongFormNote(it)
// reverts the add // reverts the add
it.mentions?.forEach { user -> it.mentions?.forEach { user ->

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -27,11 +28,18 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import nostr.postr.events.Event import nostr.postr.events.Event
import nostr.postr.toHex
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
class AddressableNote(val address: ATag): Note(address.toNAddr()) {
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt
}
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received. // These fields are only available after the Text Note event is received.
// They are immutable after that. // They are immutable after that.
var event: Event? = null var event: Event? = null
@@ -57,18 +65,21 @@ class Note(val idHex: String) {
var lastReactionsDownloadTime: Long? = null var lastReactionsDownloadTime: Long? = null
fun id() = Hex.decode(idHex) fun id() = Hex.decode(idHex)
fun idNote() = id().toNote() open fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex() open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? { fun channel(): Channel? {
val channelHex = (event as? ChannelMessageEvent)?.channel ?: val channelHex =
(event as? ChannelMetadataEvent)?.channel ?: (event as? ChannelMessageEvent)?.channel() ?:
(event as? ChannelCreateEvent)?.let { idHex } (event as? ChannelMetadataEvent)?.channel() ?:
(event as? ChannelCreateEvent)?.let { it.id.toHexKey() }
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) } return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
} }
fun address() = (event as? LongTextNoteEvent)?.address open fun address() = (event as? LongTextNoteEvent)?.address()
open fun createdAt() = event?.createdAt
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) { fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event this.event = event
@@ -90,14 +101,14 @@ class Note(val idHex: String) {
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String { fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
val replyTo = replyTo val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) { if (replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";" return "/" + formattedDateTime(createdAt() ?: 0) + ";"
} }
return replyTo return replyTo
.map { .map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) } cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
} }
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";" .maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
} }
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int { fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
@@ -236,7 +247,7 @@ class Note(val idHex: String) {
val dayAgo = Date().time / 1000 - 24*60*60 val dayAgo = Date().time / 1000 - 24*60*60
return reports.isNotEmpty() || return reports.isNotEmpty() ||
(author?.reports?.values?.filter { (author?.reports?.values?.filter {
it.firstOrNull { ( it.event?.createdAt ?: 0 ) > dayAgo } != null it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null
}?.isNotEmpty() ?: false) }?.isNotEmpty() ?: false)
} }
@@ -283,7 +294,7 @@ class Note(val idHex: String) {
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
val currentTime = Date().time / 1000 val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
} }
fun boostedBy(loggedIn: User): List<Note> { fun boostedBy(loggedIn: User): List<Note> {
@@ -356,12 +367,21 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleEventDataSource.add(note.idHex) if (note is AddressableNote) {
NostrSingleEventDataSource.addAddress(note)
} else {
NostrSingleEventDataSource.add(note)
}
} }
override fun onInactive() { override fun onInactive() {
super.onInactive() super.onInactive()
NostrSingleEventDataSource.remove(note.idHex) if (note is AddressableNote) {
NostrSingleEventDataSource.removeAddress(note)
} else {
NostrSingleEventDataSource.remove(note)
}
} }
} }

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.model.ATag
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@@ -34,7 +35,16 @@ class ThreadAssembler {
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> { fun findThreadFor(noteId: String): Set<Note> {
val (result, elapsed) = measureTimedValue { val (result, elapsed) = measureTimedValue {
val note = LocalCache.getOrCreateNote(noteId) val note = if (noteId.startsWith("naddr")) {
val aTag = ATag.parse(noteId)
if (aTag != null)
LocalCache.getOrCreateAddressableNote(aTag)
else
return emptySet()
} else {
LocalCache.getOrCreateNote(noteId)
}
if (note.event != null) { if (note.event != null) {
val thread = mutableSetOf<Note>() val thread = mutableSetOf<Note>()

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -38,8 +37,7 @@ class User(val pubkeyHex: String) {
var notes = setOf<Note>() var notes = setOf<Note>()
private set private set
var longFormNotes = mapOf<String, Set<Note>>()
private set
var taggedPosts = setOf<Note>() var taggedPosts = setOf<Note>()
private set private set
@@ -145,27 +143,8 @@ class User(val pubkeyHex: String) {
notes = notes - note notes = notes - note
} }
fun addLongFormNote(note: Note) {
val address = (note.event as LongTextNoteEvent).address
if (address in longFormNotes.keys) {
if (longFormNotes[address]?.contains(note) == false)
longFormNotes = longFormNotes + Pair(address, (longFormNotes[address] ?: emptySet()) + note)
} else {
longFormNotes = longFormNotes + Pair(address, setOf(note))
// No need for Listener yet
}
}
fun removeLongFormNote(note: Note) {
val address = (note.event as LongTextNoteEvent).address ?: return
longFormNotes = longFormNotes - address
}
fun clearNotes() { fun clearNotes() {
notes = setOf<Note>() notes = setOf<Note>()
longFormNotes = mapOf<String, Set<Note>>()
} }
fun addReport(note: Note) { fun addReport(note: Note) {
@@ -179,7 +158,7 @@ class User(val pubkeyHex: String) {
liveSet?.reports?.invalidateData() liveSet?.reports?.invalidateData()
} }
val reportTime = note.event?.createdAt ?: 0 val reportTime = note.createdAt() ?: 0
if (reportTime > latestReportTime) { if (reportTime > latestReportTime) {
latestReportTime = reportTime latestReportTime = reportTime
} }
@@ -311,7 +290,7 @@ class User(val pubkeyHex: String) {
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports[loggedIn]?.firstOrNull() { return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor.any { it.reportType == type } it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null } != null
} }
@@ -364,6 +343,7 @@ data class RelayInfo (
data class Chatroom(var roomMessages: Set<Note>) data class Chatroom(var roomMessages: Set<Note>)
class UserMetadata { class UserMetadata {
var name: String? = null var name: String? = null
var username: String? = null var username: String? = null

View File

@@ -1,12 +1,18 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.ATag
import java.nio.ByteBuffer
import java.nio.ByteOrder
import nostr.postr.Bech32
import nostr.postr.bechToBytes import nostr.postr.bechToBytes
import nostr.postr.toByteArray
class Nip19 { class Nip19 {
enum class Type { enum class Type {
USER, NOTE USER, NOTE, RELAY, ADDRESS
} }
data class Return(val type: Type, val hex: String) data class Return(val type: Type, val hex: String)
@@ -24,16 +30,31 @@ class Nip19 {
} }
if (key.startsWith("nprofile")) { if (key.startsWith("nprofile")) {
val tlv = parseTLV(bytes) val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey() val hex = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toHexKey()
if (hex != null) if (hex != null)
return Return(Type.USER, hex) return Return(Type.USER, hex)
} }
if (key.startsWith("nevent")) { if (key.startsWith("nevent")) {
val tlv = parseTLV(bytes) val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey() val hex = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toHexKey()
if (hex != null) if (hex != null)
return Return(Type.USER, hex) return Return(Type.USER, hex)
} }
if (key.startsWith("nrelay")) {
val tlv = parseTLV(bytes)
val relayUrl = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8)
if (relayUrl != null)
return Return(Type.RELAY, relayUrl)
}
if (key.startsWith("naddr")) {
val tlv = parseTLV(bytes)
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8)
val relay = tlv.get(NIP19TLVTypes.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey()
val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) }
if (d != null)
return Return(Type.ADDRESS, "$kind:$author:$d")
}
} }
} catch (e: Throwable) { } catch (e: Throwable) {
println("Issue trying to Decode NIP19 ${uri}: ${e.message}") println("Issue trying to Decode NIP19 ${uri}: ${e.message}")
@@ -42,6 +63,19 @@ class Nip19 {
return null return null
} }
}
enum class NIP19TLVTypes(val id: Byte) { //classes should start with an uppercase letter in kotlin
SPECIAL(0),
RELAY(1),
AUTHOR(2),
KIND(3);
}
fun toInt32(bytes: ByteArray): Int {
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
}
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> { fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>() var result = mutableMapOf<Byte, MutableList<ByteArray>>()
@@ -60,4 +94,3 @@ class Nip19 {
} }
return result return result
} }
}

View File

@@ -74,7 +74,7 @@ abstract class NostrDataSource(val debugName: String) {
RepostEvent.kind -> { RepostEvent.kind -> {
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig) val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) } repostEvent.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(repostEvent) LocalCache.consume(repostEvent)
} }
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(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))
@@ -83,7 +83,7 @@ abstract class NostrDataSource(val debugName: String) {
LnZapEvent.kind -> { LnZapEvent.kind -> {
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig) val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
zapEvent.containedPost?.let { onEvent(it, subscriptionId, relay) } zapEvent.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(zapEvent) LocalCache.consume(zapEvent)
} }
LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -17,10 +17,11 @@ import nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<String>() private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createAddressFilter(): List<TypedFilter>? { private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }.filter { it.address() != null } val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
if (addressesToWatch.isEmpty()) { if (addressesToWatch.isEmpty()) {
return null return null
@@ -31,22 +32,24 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
return addressesToWatch.filter { return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10) lastTime == null || lastTime < (now - 10)
}.map { }.mapNotNull {
it.address()?.let { aTag ->
TypedFilter( TypedFilter(
types = FeedType.values().toSet(), types = FeedType.values().toSet(),
filter = JsonFilter( filter = JsonFilter(
kinds = listOf( kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
), ),
tags = mapOf("a" to listOf(it.address()!!)), tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime since = it.lastReactionsDownloadTime
) )
) )
} }
} }
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? { private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) } val reactionsToWatch = eventsToWatch
if (reactionsToWatch.isEmpty()) { if (reactionsToWatch.isEmpty()) {
return null return null
@@ -73,11 +76,9 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? { fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch val directEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null } .filter { it.event == null }
val threadingEventsToLoad = eventsToWatch val threadingEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.mapNotNull { it.replyTo } .mapNotNull { it.replyTo }
.flatten() .flatten()
.filter { it.event == null } .filter { it.event == null }
@@ -107,7 +108,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
val singleEventChannel = requestNewChannel { time -> val singleEventChannel = requestNewChannel { time ->
eventsToWatch.forEach { eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time it.lastReactionsDownloadTime = time
} }
// Many relays operate with limits in the amount of filters. // Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data. // As information comes, the filters will be rotated to get more data.
@@ -122,13 +123,23 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses).flatten().ifEmpty { null } singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses).flatten().ifEmpty { null }
} }
fun add(eventId: String) { fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId) eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters() invalidateFilters()
} }
fun remove(eventId: String) { fun remove(eventId: Note) {
eventsToWatch = eventsToWatch.minus(eventId) eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters() invalidateFilters()
} }
fun addAddress(aTag: Note) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
}
fun removeAddress(aTag: Note) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
}
} }

View File

@@ -0,0 +1,72 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.NIP19TLVTypes
import com.vitorpamplona.amethyst.service.parseTLV
import com.vitorpamplona.amethyst.service.toInt32
import fr.acinq.secp256k1.Hex
import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
fun toNAddr(): String {
val kind = kind.toByteArray()
val addr = pubKeyHex.toByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val fullArray =
byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag +
byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr +
byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
}
companion object {
fun parse(address: String): ATag? {
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr"))
parseNAddr(address)
else
parseAtag(address)
}
fun parseAtag(atag: String): ATag? {
return try {
val parts = atag.split(":")
Hex.decode(parts[1])
ATag(parts[0].toInt(), parts[1], parts[2])
} catch (t: Throwable) {
Log.w("Address", "Error parsing A Tag: ${atag}: ${t.message}")
null
}
}
fun parseNAddr(naddr: String): ATag? {
try {
val key = naddr.removePrefix("nostr:")
if (key.startsWith("naddr")) {
val tlv = parseTLV(key.bechToBytes())
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
val relay = tlv.get(NIP19TLVTypes.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey()
val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) }
if (kind != null && author != null)
return ATag(kind, author, d)
}
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 ${this}: ${e.message}")
//e.printStackTrace()
}
return null
}
}
}

View File

@@ -14,16 +14,12 @@ class ChannelCreateEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channelInfo: ChannelData fun channelInfo() = try {
init {
channelInfo = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java) MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null) ChannelData(null, null, null)
} }
}
companion object { companion object {
const val kind = 40 const val kind = 40

View File

@@ -12,11 +12,7 @@ class ChannelHideMessageEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val eventsToHide: List<String> fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
init {
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 43 const val kind = 43

View File

@@ -12,15 +12,10 @@ class ChannelMessageEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
init { fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) } fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 42 const val kind = 42

View File

@@ -14,19 +14,14 @@ class ChannelMetadataEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String? fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
@Transient val channelInfo: ChannelCreateEvent.ChannelData fun channelInfo() =
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
channelInfo =
try { try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelCreateEvent.ChannelData(null, null, null) ChannelCreateEvent.ChannelData(null, null, null)
} }
}
companion object { companion object {
const val kind = 41 const val kind = 41

View File

@@ -12,11 +12,9 @@ class ChannelMuteUserEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val usersToMute: List<String>
init { fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 44 const val kind = 44

View File

@@ -14,32 +14,26 @@ class LnZapEvent (
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String> fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
@Transient val zappedAuthor: List<String> fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
@Transient val containedPost: Event?
@Transient val lnInvoice: String?
@Transient val preimage: String?
@Transient val amount: BigDecimal?
init { fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
lnInvoice = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun lnInvoice() = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
amount = lnInvoice?.let { LnInvoiceUtil.getAmountInSats(lnInvoice) } fun preimage() = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
preimage = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
val description = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
containedPost = try { // Keeps this as a field because it's a heavier function used everywhere.
if (description == null) val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
null
else fun containedPost() = try {
fromJson(description, Client.lenient) description()?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
}
companion object { companion object {
const val kind = 9735 const val kind = 9735

View File

@@ -13,14 +13,9 @@ class LnZapRequestEvent (
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
@Transient val zappedPost: List<String> fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
@Transient val zappedAuthor: List<String> fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
init {
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 9734 const val kind = 9734
@@ -34,7 +29,7 @@ class LnZapRequestEvent (
listOf("relays") + relays listOf("relays") + relays
) )
if (originalNote is LongTextNoteEvent) { if (originalNote is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", originalNote.address) ) tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
} }
val id = generateId(pubKey, createdAt, kind, tags, content) val id = generateId(pubKey, createdAt, kind, tags, content)

View File

@@ -13,34 +13,22 @@ class LongTextNoteEvent(
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val replyTos: List<String> fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
@Transient val mentions: List<String> fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
@Transient val title: String? fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
@Transient val image: String? fun address() = ATag(kind, pubKey.toHexKey(), dTag())
@Transient val summary: String?
@Transient val publishedAt: Long?
@Transient val topics: List<String>
@Transient val address: String
@Transient val dTag: String?
init { fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
replyTos = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
dTag = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() fun publishedAt() = try {
address = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "$kind:${pubKey.toHexKey()}:$dTag"
topics = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
title = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
image = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
summary = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
publishedAt = try {
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong() tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
}
companion object { companion object {
const val kind = 30023 const val kind = 30023

View File

@@ -14,13 +14,9 @@ class ReactionEvent (
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val originalPost: List<String> fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
@Transient val originalAuthor: List<String> fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
init {
originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 7 const val kind = 7
@@ -38,7 +34,7 @@ class ReactionEvent (
var tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex())) var tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
if (originalNote is LongTextNoteEvent) { if (originalNote is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", originalNote.address) ) tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
} }
val id = generateId(pubKey, createdAt, kind, tags, content) val id = generateId(pubKey, createdAt, kind, tags, content)

View File

@@ -17,12 +17,8 @@ class ReportEvent (
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportedPost: List<ReportedKey> private fun defaultReportType(): ReportType {
@Transient val reportedAuthor: List<ReportedKey>
init {
// Works with old and new structures for report. // Works with old and new structures for report.
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
if (reportType == null) { if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
@@ -30,25 +26,28 @@ class ReportEvent (
if (reportType == null) { if (reportType == null) {
reportType = ReportType.SPAM reportType = ReportType.SPAM
} }
return reportType
}
reportedPost = tags fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.map { .map {
ReportedKey( ReportedKey(
it[1], it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
) )
} }
reportedAuthor = tags fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.map { .map {
ReportedKey( ReportedKey(
it[1], it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
) )
} }
}
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
companion object { companion object {
const val kind = 1984 const val kind = 1984
@@ -63,7 +62,7 @@ class ReportEvent (
var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag) var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) { if (reportedPost is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", reportedPost.address) ) tags = tags + listOf( listOf("a", reportedPost.address().toTag()) )
} }
val id = generateId(pubKey, createdAt, kind, tags, content) val id = generateId(pubKey, createdAt, kind, tags, content)

View File

@@ -15,20 +15,16 @@ class RepostEvent (
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
@Transient val containedPost: Event?
init { fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
containedPost = try { fun containedPost() = try {
fromJson(content, Client.lenient) fromJson(content, Client.lenient)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
}
companion object { companion object {
const val kind = 6 const val kind = 6
@@ -43,7 +39,7 @@ class RepostEvent (
var tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor)) var tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) { if (boostedPost is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", boostedPost.address) ) tags = tags + listOf( listOf("a", boostedPost.address().toTag()) )
} }
val id = generateId(pubKey, createdAt, kind, tags, content) val id = generateId(pubKey, createdAt, kind, tags, content)

View File

@@ -12,20 +12,14 @@ class TextNoteEvent(
content: String, content: String,
sig: ByteArray sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) { ): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val replyTos: List<String> fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
@Transient val mentions: List<String> fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
@Transient val longFormAddress: List<String> fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
init {
longFormAddress = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }
replyTos = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object { companion object {
const val kind = 1 const val kind = 1
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey) val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>() val tags = mutableListOf<List<String>>()
replyTos?.forEach { replyTos?.forEach {
@@ -35,7 +29,7 @@ class TextNoteEvent(
tags.add(listOf("p", it)) tags.add(listOf("p", it))
} }
addresses?.forEach { addresses?.forEach {
tags.add(listOf("a", it)) tags.add(listOf("a", it.toTag()))
} }
val id = generateId(pubKey, createdAt, kind, tags, msg) val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey) val sig = Utils.sign(id, privateKey)

View File

@@ -16,6 +16,6 @@ object ChannelFeedFilter: FeedFilter<Note>() {
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
return channel.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList() return channel.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.createdAt() }?.reversed() ?: emptyList()
} }
} }

View File

@@ -23,6 +23,6 @@ object ChatroomFeedFilter: FeedFilter<Note>() {
val messages = myAccount.userProfile().privateChatrooms[myUser] ?: return emptyList() val messages = myAccount.userProfile().privateChatrooms[myUser] ?: return emptyList()
return messages.roomMessages.filter { myAccount.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed() return messages.roomMessages.filter { myAccount.isAcceptable(it) }.sortedBy { it.createdAt() }.reversed()
} }
} }

View File

@@ -17,17 +17,17 @@ object ChatroomListKnownFeedFilter: FeedFilter<Note>() {
val privateMessages = messagingWith.mapNotNull { val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy { privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt it.createdAt()
}?.lastOrNull { }?.lastOrNull {
it.event != null it.event != null
} }
} }
val publicChannels = account.followingChannels().map { val publicChannels = account.followingChannels().map {
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null } it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.createdAt() }.lastOrNull { it.event != null }
} }
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed() return (privateMessages + publicChannels).filterNotNull().sortedBy { it.createdAt() }.reversed()
} }
} }

View File

@@ -17,13 +17,13 @@ object ChatroomListNewFeedFilter: FeedFilter<Note>() {
val privateMessages = messagingWith.mapNotNull { val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy { privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt it.createdAt()
}?.lastOrNull { }?.lastOrNull {
it.event != null it.event != null
} }
} }
return privateMessages.sortedBy { it.event?.createdAt }.reversed() return privateMessages.sortedBy { it.createdAt() }.reversed()
} }
} }

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter: FeedFilter<Note>() { object GlobalFeedFilter: FeedFilter<Note>() {
@@ -11,11 +12,17 @@ object GlobalFeedFilter: FeedFilter<Note>() {
override fun feed() = LocalCache.notes.values override fun feed() = LocalCache.notes.values
.filter { .filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) || (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent)
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty()) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels())
// does not show people the user already follows
&& (it.author !in account.userProfile().follows)
} }
.filter { account.isAcceptable(it) } .filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt } .sortedBy { it.createdAt() }
.reversed() .reversed()
} }

View File

@@ -20,7 +20,7 @@ object HomeConversationsFeedFilter: FeedFilter<Note>() {
&& it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true && it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true
&& !it.isNewThread() && !it.isNewThread()
} }
.sortedBy { it.event?.createdAt } .sortedBy { it.createdAt() }
.reversed() .reversed()
} }
} }

View File

@@ -13,7 +13,7 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val user = account.userProfile() val user = account.userProfile()
return LocalCache.notes.values val notes = LocalCache.notes.values
.filter { .filter {
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent)
&& it.author in user.follows && it.author in user.follows
@@ -21,7 +21,18 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
&& it.author?.let { !account.isHidden(it) } ?: true && it.author?.let { !account.isHidden(it) } ?: true
&& it.isNewThread() && it.isNewThread()
} }
.sortedBy { it.event?.createdAt }
val longFormNotes = LocalCache.addressables.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent)
&& it.author in user.follows
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
&& it.author?.let { !account.isHidden(it) } ?: true
&& it.isNewThread()
}
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed() .reversed()
} }
} }

View File

@@ -63,7 +63,7 @@ object NotificationFeedFilter: FeedFilter<Note>() {
) )
} }
.sortedBy { it.event?.createdAt } .sortedBy { it.createdAt() }
.reversed() .reversed()
} }
} }

View File

@@ -17,7 +17,7 @@ object UserProfileConversationsFeedFilter: FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
return user?.notes return user?.notes
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() } ?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
?.sortedBy { it.event?.createdAt } ?.sortedBy { it.createdAt() }
?.reversed() ?.reversed()
?: emptyList() ?: emptyList()
} }

View File

@@ -15,9 +15,11 @@ object UserProfileNewThreadFeedFilter: FeedFilter<Note>() {
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
return user?.notes?.plus(user?.longFormNotes?.values?.flatten() ?: emptySet()) val longFormNotes = LocalCache.addressables.values.filter { it.author == user }
return user?.notes?.plus(longFormNotes)
?.filter { account?.isAcceptable(it) == true && it.isNewThread() } ?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
?.sortedBy { it.event?.createdAt } ?.sortedBy { it.createdAt() }
?.reversed() ?.reversed()
?: emptyList() ?: emptyList()
} }

View File

@@ -12,6 +12,6 @@ object UserProfileReportsFeedFilter: FeedFilter<Note>() {
} }
override fun feed(): List<Note> { override fun feed(): List<Note> {
return user?.reports?.values?.flatten()?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList() return user?.reports?.values?.flatten()?.sortedBy { it.createdAt() }?.reversed() ?: emptyList()
} }
} }

View File

@@ -107,7 +107,7 @@ private fun homeHasNewItems(account: Account, cache: NotificationCache, context:
HomeNewThreadFeedFilter.account = account HomeNewThreadFeedFilter.account = account
return (HomeNewThreadFeedFilter.feed().firstOrNull { it.event?.createdAt != null }?.event?.createdAt ?: 0) > lastTime return (HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime
} }
private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
@@ -115,17 +115,17 @@ private fun notificationHasNewItems(account: Account, cache: NotificationCache,
NotificationFeedFilter.account = account NotificationFeedFilter.account = account
return (NotificationFeedFilter.feed().firstOrNull { it.event?.createdAt != null }?.event?.createdAt ?: 0) > lastTime return (NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime
} }
private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean { private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
ChatroomListKnownFeedFilter.account = account ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.feed().firstOrNull { val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
it.event?.createdAt != null && it.channel() == null && it.author != account.userProfile() it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false } ?: return false
val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context) val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context)
return (note.event?.createdAt ?: 0) > lastTime return (note.createdAt() ?: 0) > lastTime
} }

View File

@@ -83,8 +83,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
var hasNewMessages by remember { mutableStateOf<Boolean>(false) } var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache) { LaunchedEffect(key1 = notificationCache) {
noteEvent?.let { note.createdAt()?.let {
hasNewMessages = it.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context) hasNewMessages = it > notificationCache.cache.load("Channel/${channel.idHex}", context)
} }
} }
@@ -103,7 +103,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
}, },
channelLastTime = note.event?.createdAt, channelLastTime = note.createdAt(),
channelLastContent = "${author?.toBestDisplayName()}: " + description, channelLastContent = "${author?.toBestDisplayName()}: " + description,
hasNewMessages = hasNewMessages, hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Channel/${channel.idHex}") }) onClick = { navController.navigate("Channel/${channel.idHex}") })
@@ -134,7 +134,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
ChannelName( ChannelName(
channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) }, channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) },
channelTitle = { UsernameDisplay(userToComposeOn, it) }, channelTitle = { UsernameDisplay(userToComposeOn, it) },
channelLastTime = noteEvent?.createdAt, channelLastTime = note.createdAt(),
channelLastContent = accountViewModel.decrypt(note), channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages, hasNewMessages = hasNewMessages,
onClick = { navController.navigate("Room/${user.pubkeyHex}") }) onClick = { navController.navigate("Room/${user.pubkeyHex}") })

View File

@@ -134,7 +134,7 @@ fun ChatroomMessageCompose(
routeForLastRead?.let { routeForLastRead?.let {
val lastTime = NotificationCache.load(it, context) val lastTime = NotificationCache.load(it, context)
val createdAt = note.event?.createdAt val createdAt = note.createdAt()
if (createdAt != null) { if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt, context) NotificationCache.markAsRead(it, createdAt, context)
isNew = createdAt > lastTime isNew = createdAt > lastTime
@@ -241,16 +241,16 @@ fun ChatroomMessageCompose(
val event = note.event val event = note.event
if (event is ChannelCreateEvent) { if (event is ChannelCreateEvent) {
Text(text = note.author?.toBestDisplayName() Text(text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (event.channelInfo.name .toString() + " ${stringResource(R.string.created)} " + (event.channelInfo().name
?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo.about ?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo().about
?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo.picture ?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo().picture
?: "") + "'" ?: "") + "'"
) )
} else if (event is ChannelMetadataEvent) { } else if (event is ChannelMetadataEvent) {
Text(text = note.author?.toBestDisplayName() Text(text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo.name .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo().name
?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo.about ?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo().about
?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo.picture ?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo().picture
?: "") + "'" ?: "") + "'"
) )
} else { } else {
@@ -295,7 +295,7 @@ fun ChatroomMessageCompose(
) { ) {
Row() { Row() {
Text( Text(
timeAgoShort(note.event?.createdAt, context), timeAgoShort(note.createdAt(), context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp fontSize = 12.sp
) )

View File

@@ -107,7 +107,7 @@ fun NoteCompose(
routeForLastRead?.let { routeForLastRead?.let {
val lastTime = NotificationCache.load(it, context) val lastTime = NotificationCache.load(it, context)
val createdAt = noteEvent.createdAt val createdAt = note.createdAt()
if (createdAt != null) { if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt, context) NotificationCache.markAsRead(it, createdAt, context)
isNew = createdAt > lastTime isNew = createdAt > lastTime
@@ -241,7 +241,7 @@ fun NoteCompose(
} }
Text( Text(
timeAgo(noteEvent.createdAt, context = context), timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1 maxLines = 1
) )
@@ -322,7 +322,7 @@ fun NoteCompose(
) )
} }
} else if (noteEvent is ReportEvent) { } else if (noteEvent is ReportEvent) {
val reportType = (noteEvent.reportedPost + noteEvent.reportedAuthor).map { val reportType = (noteEvent.reportedPost() + noteEvent.reportedAuthor()).map {
when (it.reportType) { when (it.reportType) {
ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content)
ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity)
@@ -343,50 +343,7 @@ fun NoteCompose(
thickness = 0.25.dp thickness = 0.25.dp
) )
} else if (noteEvent is LongTextNoteEvent) { } else if (noteEvent is LongTextNoteEvent) {
Row( LongFormHeader(noteEvent)
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
noteEvent.image?.let {
AsyncImage(
model = noteEvent.image,
contentDescription = stringResource(
R.string.preview_card_image_for,
noteEvent.image
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
}
noteEvent.title?.let {
Text(
text = it,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
noteEvent.summary?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
ReactionsRow(note, accountViewModel) ReactionsRow(note, accountViewModel)
@@ -429,6 +386,56 @@ fun NoteCompose(
} }
} }
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
Row(
modifier = Modifier
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
noteEvent.image()?.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.preview_card_image_for,
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
}
noteEvent.title()?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
)
}
noteEvent.summary()?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@Composable @Composable
private fun RelayBadges(baseNote: Note) { private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState() val noteRelaysState by baseNote.live().relays.observeAsState()

View File

@@ -10,14 +10,14 @@ abstract class Card() {
class NoteCard(val note: Note): Card() { class NoteCard(val note: Note): Card() {
override fun createdAt(): Long { override fun createdAt(): Long {
return note.event?.createdAt ?: 0 return note.createdAt() ?: 0
} }
override fun id() = note.idHex override fun id() = note.idHex
} }
class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() { class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
val createdAt = likeEvents.maxOf { it.event?.createdAt ?: 0 } val createdAt = likeEvents.maxOf { it.createdAt() ?: 0 }
override fun createdAt(): Long { override fun createdAt(): Long {
return createdAt return createdAt
} }
@@ -25,7 +25,7 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
} }
class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() { class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() {
val createdAt = zapEvents.maxOf { it.value.event?.createdAt ?: 0 } val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
override fun createdAt(): Long { override fun createdAt(): Long {
return createdAt return createdAt
} }
@@ -34,9 +34,9 @@ class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() {
class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>): Card() { class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents: List<Note>, val zapEvents: Map<Note, Note>): Card() {
val createdAt = maxOf( val createdAt = maxOf(
zapEvents.maxOfOrNull { it.value.event?.createdAt ?: 0 } ?: 0 , zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0 ,
likeEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 , likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 ,
boostEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
) )
override fun createdAt(): Long { override fun createdAt(): Long {
@@ -46,7 +46,7 @@ class MultiSetCard(val note: Note, val boostEvents: List<Note>, val likeEvents:
} }
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() { class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 } val createdAt = boostEvents.maxOf { it.createdAt() ?: 0 }
override fun createdAt(): Long { override fun createdAt(): Long {
return createdAt return createdAt

View File

@@ -97,11 +97,6 @@ private fun FeedLoaded(
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
LaunchedEffect(Unit) {
delay(500)
listState.animateScrollToItem(0)
}
LazyColumn( LazyColumn(
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = 10.dp, top = 10.dp,

View File

@@ -243,7 +243,7 @@ fun NoteMaster(baseNote: Note,
NoteUsernameDisplay(baseNote, Modifier.weight(1f)) NoteUsernameDisplay(baseNote, Modifier.weight(1f))
Text( Text(
timeAgo(noteEvent.createdAt, context = context), timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1 maxLines = 1
) )
@@ -270,19 +270,19 @@ fun NoteMaster(baseNote: Note,
if (noteEvent is LongTextNoteEvent) { if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) { Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) {
Column { Column {
noteEvent.image?.let { noteEvent.image()?.let {
AsyncImage( AsyncImage(
model = noteEvent.image, model = it,
contentDescription = stringResource( contentDescription = stringResource(
R.string.preview_card_image_for, R.string.preview_card_image_for,
noteEvent.image it
), ),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
noteEvent.title?.let { noteEvent.title()?.let {
Text( Text(
text = it, text = it,
fontSize = 30.sp, fontSize = 30.sp,
@@ -293,7 +293,7 @@ fun NoteMaster(baseNote: Note,
) )
} }
noteEvent.summary?.let { noteEvent.summary()?.let {
Text( Text(
text = it, text = it,
modifier = Modifier modifier = Modifier

View File

@@ -7,7 +7,7 @@
<string name="scan_qr">Scan QR</string> <string name="scan_qr">Scan QR</string>
<string name="show_anyway">Show Anyway</string> <string name="show_anyway">Show Anyway</string>
<string name="post_was_flagged_as_inappropriate_by">Post was flagged as inappropriate by</string> <string name="post_was_flagged_as_inappropriate_by">Post was flagged as inappropriate by</string>
<string name="post_not_found">post not found</string> <string name="post_not_found">Post not found</string>
<string name="channel_image">Channel Image</string> <string name="channel_image">Channel Image</string>
<string name="referenced_event_not_found">Referenced event not found</string> <string name="referenced_event_not_found">Referenced event not found</string>
<string name="could_not_decrypt_the_message">Could Not decrypt the message</string> <string name="could_not_decrypt_the_message">Could Not decrypt the message</string>

View File

@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.toNAddr
import org.junit.Assert.assertEquals
import org.junit.Test
class NIP19ParserTest {
@Test
fun nAddrParser() {
val result = Nip19().uriToRoute("nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus")
assertEquals("30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", result?.hex)
}
@Test
fun nAddrParser2() {
val result = Nip19().uriToRoute("nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8")
assertEquals("30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", result?.hex)
}
@Test
fun nAddrFormatter() {
val address = ATag(30023, "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", "" )
assertEquals("naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", address.toNAddr())
}
@Test
fun nAddrFormatter2() {
val address = ATag(30023, "d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c", "guide-wireguard" )
assertEquals("naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", address.toNAddr())
}
}