diff --git a/README.md b/README.md index 7a2e5c813..ae056b95a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Amethyst brings the best social network to your Android phone. Just insert your - [x] URI Support (NIP-21) - [x] Event Deletion (NIP-09: like, boost, text notes and reports) - [x] Identity Verification (NIP-05) +- [x] Long-form Content (NIP-23) +- [x] Parameterized Replaceable Events (NIP-33) - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] 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) - [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Events with a Subject (NIP-14) -- [ ] Long-form Content (NIP-23) - [ ] Online Relay Search (NIP-50) - [ ] Workspaces - [ ] Expiration Support (NIP-40) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1eb820b1d..ed433bebf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -290,13 +290,13 @@ class Account( val repliesToHex = replyTo?.map { it.idHex } val mentionsHex = mentions?.map { it.pubkeyHex } - val addressesHex = replyTo?.mapNotNull { it.address() } + val addresses = replyTo?.mapNotNull { it.address() } val signedEvent = TextNoteEvent.create( msg = message, replyTos = repliesToHex, mentions = mentionsHex, - addresses = addressesHex, + addresses = addresses, privateKey = loggedIn.privKey!! ) Client.send(signedEvent) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index a24445dcc..882b46efa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -51,7 +51,7 @@ class Channel(val idHex: String) { fun pruneOldAndHiddenMessages(account: Account): Set { val important = notes.values .filter { it.author?.let { it1 -> account.isHidden(it1) } == false } - .sortedBy { it.event?.createdAt } + .sortedBy { it.createdAt() } .reversed() .take(1000) .toSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index da84d2fa4..66b388368 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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.ChannelHideMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -52,6 +53,7 @@ object LocalCache { val users = ConcurrentHashMap() val notes = ConcurrentHashMap() val channels = ConcurrentHashMap() + val addressables = ConcurrentHashMap() fun checkGetOrCreateUser(key: String): User? { 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) { // new event @@ -159,8 +184,9 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } - val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } + val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } note.loadEvent(event, author, mentions, replyTo) @@ -193,7 +219,7 @@ object LocalCache { return } - val note = getOrCreateNote(event.id.toHex()) + val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey.toHexKey()) if (relay != null) { @@ -202,27 +228,26 @@ object LocalCache { } // 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) } - note.loadEvent(event, author, mentions, replyTo) + if (event.createdAt > (note.createdAt() ?: 0)) { + 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. + mentions.forEach { + it.addTaggedPost(note) + } + replyTo.forEach { + it.author?.addTaggedPost(note) + } - // Adds notifications to users. - mentions.forEach { - it.addTaggedPost(note) + refreshObservers() } - replyTo.forEach { - it.author?.addTaggedPost(note) - } - - refreshObservers() } private fun findCitations(event: Event): Set { @@ -245,13 +270,13 @@ object LocalCache { private fun replyToWithoutCitations(event: TextNoteEvent): List { val citations = findCitations(event) - return event.replyTos.filter { it !in citations } + return event.replyTos().filter { it !in citations } } private fun replyToWithoutCitations(event: LongTextNoteEvent): List { val citations = findCitations(event) - return event.replyTos.filter { it !in citations } + return event.replyTos().filter { it !in citations } } 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)}") val author = getOrCreateUser(event.pubKey.toHexKey()) - val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.boostedPost.mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } note.loadEvent(event, author, mentions, repliesTo) @@ -409,8 +435,9 @@ object LocalCache { if (note.event != null) return val author = getOrCreateUser(event.pubKey.toHexKey()) - val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.originalPost.mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } note.loadEvent(event, author, mentions, repliesTo) @@ -459,8 +486,9 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it.key) } - val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it.key) } + val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) } + val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } note.loadEvent(event, author, mentions, repliesTo) @@ -483,7 +511,7 @@ object LocalCache { val author = getOrCreateUser(event.pubKey.toHexKey()) if (event.createdAt > oldChannel.updatedMetadataAt) { 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()) oldChannel.addNote(note) @@ -496,15 +524,16 @@ object LocalCache { } } fun consume(event: ChannelMetadataEvent) { + val channelId = event.channel() //Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}") - if (event.channel.isNullOrBlank()) return + if (channelId.isNullOrBlank()) return // new event - val oldChannel = checkGetOrCreateChannel(event.channel) ?: return + val oldChannel = checkGetOrCreateChannel(channelId) ?: return val author = getOrCreateUser(event.pubKey.toHexKey()) if (event.createdAt > oldChannel.updatedMetadataAt) { 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()) oldChannel.addNote(note) @@ -518,7 +547,9 @@ object LocalCache { } fun consume(event: ChannelMessageEvent, relay: Relay?) { - if (event.channel.isNullOrBlank()) return + val channelId = event.channel() + + if (channelId.isNullOrBlank()) return if (antiSpam.isSpam(event)) { relay?.let { it.spamCounter++ @@ -526,7 +557,7 @@ object LocalCache { return } - val channel = checkGetOrCreateChannel(event.channel) ?: return + val channel = checkGetOrCreateChannel(channelId) ?: return val note = getOrCreateNote(event.id.toHex()) channel.addNote(note) @@ -541,8 +572,8 @@ object LocalCache { // Already processed this event. if (note.event != null) return - val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) } - val replyTo = event.replyTos + val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) } + val replyTo = event.replyTos() .mapNotNull { checkGetOrCreateNote(it) } .filter { it.event !is ChannelCreateEvent } @@ -580,13 +611,16 @@ object LocalCache { // Already processed this event. if (note.event != null) return + val zapRequest = event.containedPost()?.id?.toHexKey()?.let { getOrCreateNote(it) } + val author = getOrCreateUser(event.pubKey.toHexKey()) - val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().map { getOrCreateAddressableNote(it) } + + ((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet()) note.loadEvent(event, author, mentions, repliesTo) - val zapRequest = event.containedPost?.id?.toHexKey()?.let { getOrCreateNote(it) } if (zapRequest == null) { Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}") return @@ -617,8 +651,9 @@ object LocalCache { if (note.event != null) return val author = getOrCreateUser(event.pubKey.toHexKey()) - val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) } - val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) } + val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) } + val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + + event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) } note.loadEvent(event, author, mentions, repliesTo) @@ -652,9 +687,13 @@ object LocalCache { return notes.values.filter { (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 LongTextNoteEvent && it.event?.content?.contains(text, true) ?: false) || it.idHex.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) { val toBeRemoved = account.hiddenUsers.map { - (users[it]?.notes ?: emptySet()) + (users[it]?.longFormNotes?.values?.flatten() ?: emptySet()) + (users[it]?.notes ?: emptySet()) }.flatten() account.hiddenUsers.forEach { @@ -747,7 +786,6 @@ object LocalCache { toBeRemoved.forEach { it.author?.removeNote(it) - it.author?.removeLongFormNote(it) // reverts the add it.mentions?.forEach { user -> diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 14db2aa01..12d130b8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData 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.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -27,11 +28,18 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nostr.postr.events.Event -import nostr.postr.toHex 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. // They are immutable after that. var event: Event? = null @@ -57,18 +65,21 @@ class Note(val idHex: String) { var lastReactionsDownloadTime: Long? = null fun id() = Hex.decode(idHex) - fun idNote() = id().toNote() - fun idDisplayNote() = idNote().toShortenHex() + open fun idNote() = id().toNote() + open fun idDisplayNote() = idNote().toShortenHex() fun channel(): Channel? { - val channelHex = (event as? ChannelMessageEvent)?.channel ?: - (event as? ChannelMetadataEvent)?.channel ?: - (event as? ChannelCreateEvent)?.let { idHex } + val channelHex = + (event as? ChannelMessageEvent)?.channel() ?: + (event as? ChannelMetadataEvent)?.channel() ?: + (event as? ChannelCreateEvent)?.let { it.id.toHexKey() } 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, replyTo: List) { this.event = event @@ -90,14 +101,14 @@ class Note(val idHex: String) { fun replyLevelSignature(cachedSignatures: MutableMap = mutableMapOf()): String { val replyTo = replyTo if (replyTo == null || replyTo.isEmpty()) { - return "/" + formattedDateTime(event?.createdAt ?: 0) + ";" + return "/" + formattedDateTime(createdAt() ?: 0) + ";" } return replyTo .map { 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 = mutableMapOf()): Int { @@ -236,7 +247,7 @@ class Note(val idHex: String) { val dayAgo = Date().time / 1000 - 24*60*60 return reports.isNotEmpty() || (author?.reports?.values?.filter { - it.firstOrNull { ( it.event?.createdAt ?: 0 ) > dayAgo } != null + it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null }?.isNotEmpty() ?: false) } @@ -283,7 +294,7 @@ class Note(val idHex: String) { fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { val currentTime = Date().time / 1000 - return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection + return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection } fun boostedBy(loggedIn: User): List { @@ -356,12 +367,21 @@ class NoteLiveData(val note: Note): LiveData(NoteState(note)) { override fun onActive() { super.onActive() - NostrSingleEventDataSource.add(note.idHex) + if (note is AddressableNote) { + NostrSingleEventDataSource.addAddress(note) + } else { + NostrSingleEventDataSource.add(note) + } + } override fun onInactive() { super.onInactive() - NostrSingleEventDataSource.remove(note.idHex) + if (note is AddressableNote) { + NostrSingleEventDataSource.removeAddress(note) + } else { + NostrSingleEventDataSource.remove(note) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt index f1b928928..be2366c66 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.service.model.ATag import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @@ -34,7 +35,16 @@ class ThreadAssembler { @OptIn(ExperimentalTime::class) fun findThreadFor(noteId: String): Set { 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) { val thread = mutableSetOf() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index c201b76f5..1b7de0513 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource 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.relays.Relay import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -38,8 +37,7 @@ class User(val pubkeyHex: String) { var notes = setOf() private set - var longFormNotes = mapOf>() - private set + var taggedPosts = setOf() private set @@ -145,27 +143,8 @@ class User(val pubkeyHex: String) { 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() { notes = setOf() - longFormNotes = mapOf>() } fun addReport(note: Note) { @@ -179,7 +158,7 @@ class User(val pubkeyHex: String) { liveSet?.reports?.invalidateData() } - val reportTime = note.event?.createdAt ?: 0 + val reportTime = note.createdAt() ?: 0 if (reportTime > latestReportTime) { latestReportTime = reportTime } @@ -311,7 +290,7 @@ class User(val pubkeyHex: String) { fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean { 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 } @@ -364,6 +343,7 @@ data class RelayInfo ( data class Chatroom(var roomMessages: Set) + class UserMetadata { var name: String? = null var username: String? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt index 700afe405..f4b551be1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt @@ -1,12 +1,18 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.toByteArray 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.toByteArray class Nip19 { enum class Type { - USER, NOTE + USER, NOTE, RELAY, ADDRESS } data class Return(val type: Type, val hex: String) @@ -24,16 +30,31 @@ class Nip19 { } if (key.startsWith("nprofile")) { 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) return Return(Type.USER, hex) } if (key.startsWith("nevent")) { 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) 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) { println("Issue trying to Decode NIP19 ${uri}: ${e.message}") @@ -42,22 +63,34 @@ class Nip19 { return null } +} - fun parseTLV(data: ByteArray): Map> { - var result = mutableMapOf>() - var rest = data - while (rest.isNotEmpty()) { - val t = rest[0] - val l = rest[1] - val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) - rest = rest.sliceArray(IntRange(2 + l, rest.size-1)) - if (v.size < l) continue +enum class NIP19TLVTypes(val id: Byte) { //classes should start with an uppercase letter in kotlin + SPECIAL(0), + RELAY(1), + AUTHOR(2), + KIND(3); +} - if (!result.containsKey(t)) { - result.put(t, mutableListOf()) - } - result.get(t)?.add(v) +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> { + var result = mutableMapOf>() + var rest = data + while (rest.isNotEmpty()) { + val t = rest[0] + val l = rest[1] + val v = rest.sliceArray(IntRange(2, (2 + l) - 1)) + rest = rest.sliceArray(IntRange(2 + l, rest.size-1)) + if (v.size < l) continue + + if (!result.containsKey(t)) { + result.put(t, mutableListOf()) } - return result + result.get(t)?.add(v) } -} \ No newline at end of file + return result +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index d9493450b..2956fdd7c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -74,7 +74,7 @@ abstract class NostrDataSource(val debugName: String) { RepostEvent.kind -> { 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) } 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 -> { 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) } LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index c35d2284c..90947cca0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -1,6 +1,6 @@ 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.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -17,10 +17,11 @@ import nostr.postr.JsonFilter import com.vitorpamplona.amethyst.service.model.TextNoteEvent object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { - private var eventsToWatch = setOf() + private var eventsToWatch = setOf() + private var addressesToWatch = setOf() private fun createAddressFilter(): List? { - val addressesToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }.filter { it.address() != null } + val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch if (addressesToWatch.isEmpty()) { return null @@ -31,22 +32,24 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { return addressesToWatch.filter { val lastTime = it.lastReactionsDownloadTime lastTime == null || lastTime < (now - 10) - }.map { - TypedFilter( - types = FeedType.values().toSet(), - filter = JsonFilter( - kinds = listOf( - TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind - ), - tags = mapOf("a" to listOf(it.address()!!)), - since = it.lastReactionsDownloadTime + }.mapNotNull { + it.address()?.let { aTag -> + TypedFilter( + types = FeedType.values().toSet(), + filter = JsonFilter( + kinds = listOf( + TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind + ), + tags = mapOf("a" to listOf(aTag.toTag())), + since = it.lastReactionsDownloadTime + ) ) - ) + } } } private fun createRepliesAndReactionsFilter(): List? { - val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) } + val reactionsToWatch = eventsToWatch if (reactionsToWatch.isEmpty()) { return null @@ -73,11 +76,9 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { fun createLoadEventsIfNotLoadedFilter(): List? { val directEventsToLoad = eventsToWatch - .map { LocalCache.getOrCreateNote(it) } .filter { it.event == null } val threadingEventsToLoad = eventsToWatch - .map { LocalCache.getOrCreateNote(it) } .mapNotNull { it.replyTo } .flatten() .filter { it.event == null } @@ -107,7 +108,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") { val singleEventChannel = requestNewChannel { time -> eventsToWatch.forEach { - LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time + it.lastReactionsDownloadTime = time } // Many relays operate with limits in the amount of filters. // 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 } } - fun add(eventId: String) { + fun add(eventId: Note) { eventsToWatch = eventsToWatch.plus(eventId) invalidateFilters() } - fun remove(eventId: String) { + fun remove(eventId: Note) { eventsToWatch = eventsToWatch.minus(eventId) invalidateFilters() } + + fun addAddress(aTag: Note) { + addressesToWatch = addressesToWatch.plus(aTag) + invalidateFilters() + } + + fun removeAddress(aTag: Note) { + addressesToWatch = addressesToWatch.minus(aTag) + invalidateFilters() + } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt new file mode 100644 index 000000000..0ecfd8f5d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt index c63798393..791aafecf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt @@ -14,15 +14,11 @@ class ChannelCreateEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val channelInfo: ChannelData - - init { - channelInfo = try { - MetadataEvent.gson.fromJson(content, ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelData(null, null, null) - } + fun channelInfo() = try { + MetadataEvent.gson.fromJson(content, ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelData(null, null, null) } companion object { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt index c15562778..41c526e4f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelHideMessageEvent.kt @@ -12,11 +12,7 @@ class ChannelHideMessageEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val eventsToHide: List - - init { - eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - } + fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } companion object { const val kind = 43 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt index 459fc4739..39f0e6bae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt @@ -12,15 +12,10 @@ class ChannelMessageEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val channel: String? - @Transient val replyTos: List - @Transient val mentions: List - init { - channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) - replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) } - mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - } + fun 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) } + fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } companion object { const val kind = 42 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt index 09bff41ec..2552ad89c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMetadataEvent.kt @@ -14,19 +14,14 @@ class ChannelMetadataEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val channel: String? - @Transient val channelInfo: ChannelCreateEvent.ChannelData - - init { - channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) - channelInfo = - try { - MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) - } catch (e: Exception) { - Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) - ChannelCreateEvent.ChannelData(null, null, null) - } - } + fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1) + fun channelInfo() = + try { + MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java) + } catch (e: Exception) { + Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e) + ChannelCreateEvent.ChannelData(null, null, null) + } companion object { const val kind = 41 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt index dcc52755d..23c1c52fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMuteUserEvent.kt @@ -12,11 +12,9 @@ class ChannelMuteUserEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val usersToMute: List - init { - usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - } + fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + companion object { const val kind = 44 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index acaa2e9ab..2a38c8cf4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -14,31 +14,25 @@ class LnZapEvent ( sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val zappedPost: List - @Transient val zappedAuthor: List - @Transient val containedPost: Event? - @Transient val lnInvoice: String? - @Transient val preimage: String? - @Transient val amount: BigDecimal? + fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - init { - zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } - lnInvoice = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull() - amount = lnInvoice?.let { LnInvoiceUtil.getAmountInSats(lnInvoice) } - preimage = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun lnInvoice() = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + fun 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 { - if (description == null) - null - else - fromJson(description, Client.lenient) - } catch (e: Exception) { - null + // Keeps this as a field because it's a heavier function used everywhere. + val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) } + + fun containedPost() = try { + description()?.let { + fromJson(it, Client.lenient) } + } catch (e: Exception) { + null } companion object { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 9c6b57034..049b4b434 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -13,14 +13,9 @@ class LnZapRequestEvent ( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - - @Transient val zappedPost: List - @Transient val zappedAuthor: List - - init { - zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - } + fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } companion object { const val kind = 9734 @@ -34,7 +29,7 @@ class LnZapRequestEvent ( listOf("relays") + relays ) 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt index 1c2d67558..037c23c83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt @@ -13,33 +13,21 @@ class LongTextNoteEvent( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val replyTos: List - @Transient val mentions: List + fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - @Transient val title: String? - @Transient val image: String? - @Transient val summary: String? - @Transient val publishedAt: Long? - @Transient val topics: List - @Transient val address: String - @Transient val dTag: String? + fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: "" + fun address() = ATag(kind, pubKey.toHexKey(), dTag()) - init { - replyTos = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) } + fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull() + 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() - - 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() - } catch (_: Exception) { - null - } + fun publishedAt() = try { + tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong() + } catch (_: Exception) { + null } companion object { @@ -59,4 +47,4 @@ class LongTextNoteEvent( return LongTextNoteEvent(id, pubKey, createdAt, tags, msg, sig) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt index 264ac4990..6048a0978 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt @@ -14,13 +14,9 @@ class ReactionEvent ( sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val originalPost: List - @Transient val originalAuthor: List - - init { - originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } - } + fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + 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) } companion object { const val kind = 7 @@ -38,7 +34,7 @@ class ReactionEvent ( var tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex())) 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt index 1129ff579..a1d83c155 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt @@ -17,12 +17,8 @@ class ReportEvent ( sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val reportedPost: List - @Transient val reportedAuthor: List - - init { + private fun defaultReportType(): ReportType { // 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() if (reportType == null) { reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull() @@ -30,26 +26,29 @@ class ReportEvent ( if (reportType == null) { reportType = ReportType.SPAM } - - reportedPost = tags - .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType - ) - } - - reportedAuthor = tags - .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } - .map { - ReportedKey( - it[1], - it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: reportType - ) - } + return reportType } + fun reportedPost() = tags + .filter { it.firstOrNull() == "e" && it.getOrNull(1) != null } + .map { + ReportedKey( + it[1], + it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType() + ) + } + + fun reportedAuthor() = tags + .filter { it.firstOrNull() == "p" && it.getOrNull(1) != null } + .map { + ReportedKey( + it[1], + 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 { const val kind = 1984 @@ -63,7 +62,7 @@ class ReportEvent ( var tags:List> = listOf(reportPostTag, reportAuthorTag) 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt index d0d405d5b..3da165f2c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt @@ -15,19 +15,15 @@ class RepostEvent ( sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val boostedPost: List - @Transient val originalAuthor: List - @Transient val containedPost: Event? - init { - boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } - originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } + 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) } - containedPost = try { - fromJson(content, Client.lenient) - } catch (e: Exception) { - null - } + fun containedPost() = try { + fromJson(content, Client.lenient) + } catch (e: Exception) { + null } companion object { @@ -43,7 +39,7 @@ class RepostEvent ( var tags:List> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor)) 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index e22c0418d..9e1634b16 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -12,20 +12,14 @@ class TextNoteEvent( content: String, sig: ByteArray ): Event(id, pubKey, createdAt, kind, tags, content, sig) { - @Transient val replyTos: List - @Transient val mentions: List - @Transient val longFormAddress: List - - 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) } - } + fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) } + fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) } + fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) } companion object { const val kind = 1 - fun create(msg: String, replyTos: List?, mentions: List?, addresses: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { + fun create(msg: String, replyTos: List?, mentions: List?, addresses: List?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent { val pubKey = Utils.pubkeyCreate(privateKey) val tags = mutableListOf>() replyTos?.forEach { @@ -35,7 +29,7 @@ class TextNoteEvent( tags.add(listOf("p", it)) } addresses?.forEach { - tags.add(listOf("a", it)) + tags.add(listOf("a", it.toTag())) } val id = generateId(pubKey, createdAt, kind, tags, msg) val sig = Utils.sign(id, privateKey) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index c334910fa..86a2424be 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -16,6 +16,6 @@ object ChannelFeedFilter: FeedFilter() { // returns the last Note of each user. override fun feed(): List { - 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() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index fe58f8031..ab198227e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -23,6 +23,6 @@ object ChatroomFeedFilter: FeedFilter() { 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() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 678eace2c..c6b32ee05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -17,17 +17,17 @@ object ChatroomListKnownFeedFilter: FeedFilter() { val privateMessages = messagingWith.mapNotNull { privateChatrooms[it]?.roomMessages?.sortedBy { - it.event?.createdAt + it.createdAt() }?.lastOrNull { it.event != null } } 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() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 3bb7eb510..77e7ef015 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -17,13 +17,13 @@ object ChatroomListNewFeedFilter: FeedFilter() { val privateMessages = messagingWith.mapNotNull { privateChatrooms[it]?.roomMessages?.sortedBy { - it.event?.createdAt + it.createdAt() }?.lastOrNull { it.event != null } } - return privateMessages.sortedBy { it.event?.createdAt }.reversed() + return privateMessages.sortedBy { it.createdAt() }.reversed() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index 010e3ef53..6ad966e23 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent object GlobalFeedFilter: FeedFilter() { @@ -11,11 +12,17 @@ object GlobalFeedFilter: FeedFilter() { override fun feed() = LocalCache.notes.values .filter { - (it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) || - (it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty()) + (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) + && 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) } - .sortedBy { it.event?.createdAt } + .sortedBy { it.createdAt() } .reversed() } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 1f96b3ee8..e53d0d4c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -20,7 +20,7 @@ object HomeConversationsFeedFilter: FeedFilter() { && it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true && !it.isNewThread() } - .sortedBy { it.event?.createdAt } + .sortedBy { it.createdAt() } .reversed() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index 5276c2437..76307908c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -13,7 +13,7 @@ object HomeNewThreadFeedFilter: FeedFilter() { override fun feed(): List { val user = account.userProfile() - return LocalCache.notes.values + val notes = LocalCache.notes.values .filter { (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) && it.author in user.follows @@ -21,7 +21,18 @@ object HomeNewThreadFeedFilter: FeedFilter() { && it.author?.let { !account.isHidden(it) } ?: true && 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() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 80f94cd27..c3b8d7e70 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -63,7 +63,7 @@ object NotificationFeedFilter: FeedFilter() { ) } - .sortedBy { it.event?.createdAt } + .sortedBy { it.createdAt() } .reversed() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 8c0288806..3d8415581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -17,7 +17,7 @@ object UserProfileConversationsFeedFilter: FeedFilter() { override fun feed(): List { return user?.notes ?.filter { account?.isAcceptable(it) == true && !it.isNewThread() } - ?.sortedBy { it.event?.createdAt } + ?.sortedBy { it.createdAt() } ?.reversed() ?: emptyList() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index fd5316df0..3465375cf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -15,9 +15,11 @@ object UserProfileNewThreadFeedFilter: FeedFilter() { } override fun feed(): List { - 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() } - ?.sortedBy { it.event?.createdAt } + ?.sortedBy { it.createdAt() } ?.reversed() ?: emptyList() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt index 2e527c002..fd4e383de 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileReportsFeedFilter.kt @@ -12,6 +12,6 @@ object UserProfileReportsFeedFilter: FeedFilter() { } override fun feed(): List { - return user?.reports?.values?.flatten()?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList() + return user?.reports?.values?.flatten()?.sortedBy { it.createdAt() }?.reversed() ?: emptyList() } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 8da81cfa0..bf48cecf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -107,7 +107,7 @@ private fun homeHasNewItems(account: Account, cache: NotificationCache, context: 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 { @@ -115,17 +115,17 @@ private fun notificationHasNewItems(account: Account, cache: NotificationCache, 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 { ChatroomListKnownFeedFilter.account = account 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 val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context) - return (note.event?.createdAt ?: 0) > lastTime + return (note.createdAt() ?: 0) > lastTime } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index 91e97681b..926636feb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -83,8 +83,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr var hasNewMessages by remember { mutableStateOf(false) } LaunchedEffect(key1 = notificationCache) { - noteEvent?.let { - hasNewMessages = it.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context) + note.createdAt()?.let { + 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) ) }, - channelLastTime = note.event?.createdAt, + channelLastTime = note.createdAt(), channelLastContent = "${author?.toBestDisplayName()}: " + description, hasNewMessages = hasNewMessages, onClick = { navController.navigate("Channel/${channel.idHex}") }) @@ -134,7 +134,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr ChannelName( channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) }, channelTitle = { UsernameDisplay(userToComposeOn, it) }, - channelLastTime = noteEvent?.createdAt, + channelLastTime = note.createdAt(), channelLastContent = accountViewModel.decrypt(note), hasNewMessages = hasNewMessages, onClick = { navController.navigate("Room/${user.pubkeyHex}") }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 8b6fb5539..87f749abc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -134,7 +134,7 @@ fun ChatroomMessageCompose( routeForLastRead?.let { val lastTime = NotificationCache.load(it, context) - val createdAt = note.event?.createdAt + val createdAt = note.createdAt() if (createdAt != null) { NotificationCache.markAsRead(it, createdAt, context) isNew = createdAt > lastTime @@ -241,16 +241,16 @@ fun ChatroomMessageCompose( val event = note.event if (event is ChannelCreateEvent) { Text(text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.created)} " + (event.channelInfo.name - ?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo.about - ?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo.picture + .toString() + " ${stringResource(R.string.created)} " + (event.channelInfo().name + ?: "") +" ${stringResource(R.string.with_description_of)} '" + (event.channelInfo().about + ?: "") + "', ${stringResource(R.string.and_picture)} '" + (event.channelInfo().picture ?: "") + "'" ) } else if (event is ChannelMetadataEvent) { Text(text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo.name - ?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo.about - ?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo.picture + .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (event.channelInfo().name + ?: "") + "$', {stringResource(R.string.description_to)} '" + (event.channelInfo().about + ?: "") + "', ${stringResource(R.string.and_picture_to)} '" + (event.channelInfo().picture ?: "") + "'" ) } else { @@ -295,7 +295,7 @@ fun ChatroomMessageCompose( ) { Row() { Text( - timeAgoShort(note.event?.createdAt, context), + timeAgoShort(note.createdAt(), context), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), fontSize = 12.sp ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index c56c242f0..77e332947 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -107,7 +107,7 @@ fun NoteCompose( routeForLastRead?.let { val lastTime = NotificationCache.load(it, context) - val createdAt = noteEvent.createdAt + val createdAt = note.createdAt() if (createdAt != null) { NotificationCache.markAsRead(it, createdAt, context) isNew = createdAt > lastTime @@ -241,7 +241,7 @@ fun NoteCompose( } Text( - timeAgo(noteEvent.createdAt, context = context), + timeAgo(note.createdAt(), context = context), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1 ) @@ -322,7 +322,7 @@ fun NoteCompose( ) } } else if (noteEvent is ReportEvent) { - val reportType = (noteEvent.reportedPost + noteEvent.reportedAuthor).map { + val reportType = (noteEvent.reportedPost() + noteEvent.reportedAuthor()).map { when (it.reportType) { ReportEvent.ReportType.EXPLICIT -> stringResource(R.string.explicit_content) ReportEvent.ReportType.NUDITY -> stringResource(R.string.nudity) @@ -343,50 +343,7 @@ fun NoteCompose( thickness = 0.25.dp ) } else if (noteEvent is 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 = 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 - ) - } - } - } + LongFormHeader(noteEvent) 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 private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 7b636ef14..669bc0dad 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -10,14 +10,14 @@ abstract class Card() { class NoteCard(val note: Note): Card() { override fun createdAt(): Long { - return note.event?.createdAt ?: 0 + return note.createdAt() ?: 0 } override fun id() = note.idHex } class LikeSetCard(val note: Note, val likeEvents: List): Card() { - val createdAt = likeEvents.maxOf { it.event?.createdAt ?: 0 } + val createdAt = likeEvents.maxOf { it.createdAt() ?: 0 } override fun createdAt(): Long { return createdAt } @@ -25,7 +25,7 @@ class LikeSetCard(val note: Note, val likeEvents: List): Card() { } class ZapSetCard(val note: Note, val zapEvents: Map): Card() { - val createdAt = zapEvents.maxOf { it.value.event?.createdAt ?: 0 } + val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 } override fun createdAt(): Long { return createdAt } @@ -34,9 +34,9 @@ class ZapSetCard(val note: Note, val zapEvents: Map): Card() { class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: List, val zapEvents: Map): Card() { val createdAt = maxOf( - zapEvents.maxOfOrNull { it.value.event?.createdAt ?: 0 } ?: 0 , - likeEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 , - boostEvents.maxOfOrNull { it.event?.createdAt ?: 0 } ?: 0 + zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0 , + likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 , + boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 ) override fun createdAt(): Long { @@ -46,7 +46,7 @@ class MultiSetCard(val note: Note, val boostEvents: List, val likeEvents: } class BoostSetCard(val note: Note, val boostEvents: List): Card() { - val createdAt = boostEvents.maxOf { it.event?.createdAt ?: 0 } + val createdAt = boostEvents.maxOf { it.createdAt() ?: 0 } override fun createdAt(): Long { return createdAt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index df931ea53..e78a451e9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -97,11 +97,6 @@ private fun FeedLoaded( ) { val listState = rememberLazyListState() - LaunchedEffect(Unit) { - delay(500) - listState.animateScrollToItem(0) - } - LazyColumn( contentPadding = PaddingValues( top = 10.dp, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 7f749f8d1..e0c7b07b2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -243,7 +243,7 @@ fun NoteMaster(baseNote: Note, NoteUsernameDisplay(baseNote, Modifier.weight(1f)) Text( - timeAgo(noteEvent.createdAt, context = context), + timeAgo(note.createdAt(), context = context), color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1 ) @@ -270,19 +270,19 @@ fun NoteMaster(baseNote: Note, if (noteEvent is LongTextNoteEvent) { Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)) { Column { - noteEvent.image?.let { + noteEvent.image()?.let { AsyncImage( - model = noteEvent.image, + model = it, contentDescription = stringResource( R.string.preview_card_image_for, - noteEvent.image + it ), contentScale = ContentScale.FillWidth, modifier = Modifier.fillMaxWidth() ) } - noteEvent.title?.let { + noteEvent.title()?.let { Text( text = it, fontSize = 30.sp, @@ -293,7 +293,7 @@ fun NoteMaster(baseNote: Note, ) } - noteEvent.summary?.let { + noteEvent.summary()?.let { Text( text = it, modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37cb35f12..1d6d7acef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ Scan QR Show Anyway Post was flagged as inappropriate by - post not found + Post not found Channel Image Referenced event not found Could Not decrypt the message diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt new file mode 100644 index 000000000..9389dc692 --- /dev/null +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -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()) + } +} \ No newline at end of file