Merge branch 'main' into feature/172-mark-all-read

This commit is contained in:
maxmoney21m 2023-03-04 14:17:31 +08:00 committed by GitHub
commit 42a27a6d8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
115 changed files with 2497 additions and 866 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] 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)

View File

@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 83
versionName "0.22.0"
versionCode 86
versionName "0.22.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -25,7 +25,7 @@
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken

View File

@ -8,9 +8,9 @@ import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import nostr.postr.toHex
class LocalPreferences(context: Context) {

View File

@ -6,6 +6,7 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
@ -26,16 +27,12 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
@ -89,10 +86,11 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
if (contactList != null && contactList.follows.size > 0) {
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
contactList.follows,
follows,
relays,
loggedIn.privKey!!)
@ -111,14 +109,7 @@ class Account(
if (!isWriteable()) return
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
val event = MetadataEvent.create(toString, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@ -250,10 +241,11 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val event = if (contactList != null && contactList.follows.size > 0) {
val event = if (contactList != null && follows.isNotEmpty()) {
ContactListEvent.create(
contactList.follows.plus(Contact(user.pubkeyHex, null)),
follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
@ -273,10 +265,11 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
if (contactList != null && contactList.follows.size > 0) {
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
contactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
@ -290,18 +283,20 @@ class Account(
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = replyTo?.mapNotNull { it.address() }
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@ -318,37 +313,6 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun createPrivateMessageWithReply(
recipientPubKey: ByteArray,
msg: String,
replyTos: List<String>? = null, mentions: List<String>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) {
PrivateDmEvent.nip18Advertisement
} else { "" } + msg,
privateKey,
recipientPubKey)
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
publishedRecipientPubKey?.let {
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
}
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = Event.generateId(pubKey, createdAt, PrivateDmEvent.kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@ -356,7 +320,7 @@ class Account(
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = emptyList<String>()
val signedEvent = createPrivateMessageWithReply(
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
@ -384,7 +348,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
joinChannel(event.id)
}
fun joinChannel(idHex: String) {
@ -436,7 +400,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
joinChannel(event.id)
}
fun decryptContent(note: Note): String? {
@ -444,26 +408,12 @@ class Account(
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey
val recepientPK = event.recipientPubKey()
if (note.author == userProfile() && recepientPK != null)
pubkeyToUse = recepientPK
return try {
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
val retVal = Utils.decrypt(event.content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
e.printStackTrace()
null
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else {
event?.content
}
@ -493,10 +443,10 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.follows.isNullOrEmpty()) return
if (newContactList?.follows().isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id?.toHex() != newContactList?.id?.toHex()) {
if (backupContactList?.id != newContactList?.id) {
backupContactList = newContactList
saveable.invalidateData()
}

View File

@ -11,7 +11,7 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
import nostr.postr.toHex
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
@ -22,7 +22,7 @@ class AntiSpamFilter {
@Synchronized
fun isSpam(event: Event): Boolean {
val idHex = event.id.toHexKey()
val idHex = event.id
// if already processed, ok
if (LocalCache.notes[idHex] != null) return false
@ -37,15 +37,15 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, Spammer(event.pubKey.toHexKey(), setOf(recentMessages[hash], event.id.toHex())))
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
liveSpam.invalidateData()
} else {
val spammer = spamMessages.get(hash)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id.toHex()
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
liveSpam.invalidateData()
}

View File

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

View File

@ -74,6 +74,7 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
}
} catch (e: Exception) {
e.printStackTrace()
}

View File

@ -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
@ -16,6 +17,13 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import fr.acinq.secp256k1.Hex
import java.io.ByteArrayInputStream
@ -31,13 +39,6 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import nostr.postr.toNpub
@ -52,6 +53,7 @@ object LocalCache {
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
fun checkGetOrCreateUser(key: String): User? {
return try {
@ -111,10 +113,33 @@ 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
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
val oldUser = getOrCreateUser(event.pubKey)
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue(
@ -148,8 +173,8 @@ object LocalCache {
return
}
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
@ -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,8 +219,8 @@ object LocalCache {
return
}
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
@ -202,32 +228,26 @@ object LocalCache {
}
// Already processed this event.
if (note.event != null) return
if (note.event?.id == event.id) 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.addNote(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)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
@ -250,13 +270,13 @@ object LocalCache {
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
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> {
val citations = findCitations(event)
return event.replyTos.filter { it !in citations }
return event.replyTos().filter { it !in citations }
}
fun consume(event: RecommendRelayEvent) {
@ -264,21 +284,21 @@ object LocalCache {
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey.toHexKey())
val user = getOrCreateUser(event.pubKey)
val follows = event.follows()
if (event.createdAt > user.updatedFollowsAt && event.follows.isNotEmpty()) {
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
user.updateFollows(
event.follows.map {
follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println("UpdateFollows: " + event.toJson())
e.printStackTrace()
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
//e.printStackTrace()
null
}
}.filterNotNull().toSet(),
@ -297,20 +317,17 @@ object LocalCache {
user.updateRelays(relays)
}
} catch (e: Exception) {
println("relay import issue")
Log.w("Relay List Parser","Relay import issue ${e.message}", e)
e.printStackTrace()
}
Log.d(
"CL",
"AAA ${user.toBestDisplayName()} ${event.follows.size}"
)
Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
@ -320,7 +337,7 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@ -340,9 +357,9 @@ object LocalCache {
fun consume(event: DeletionEvent) {
var deletedAtLeastOne = false
event.deleteEvents.mapNotNull { notes[it] }.forEach { deleteNote ->
event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote ->
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey.toHexKey()) {
if (deleteNote.author?.pubkeyHex == event.pubKey) {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
@ -376,16 +393,17 @@ object LocalCache {
}
fun consume(event: RepostEvent) {
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
//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 author = getOrCreateUser(event.pubKey)
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,14 +427,15 @@ object LocalCache {
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id.toHexKey())
val note = getOrCreateNote(event.id)
// Already processed this event.
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 author = getOrCreateUser(event.pubKey)
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)
@ -454,8 +473,8 @@ object LocalCache {
}
fun consume(event: ReportEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
@ -465,8 +484,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)
@ -485,15 +505,14 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
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())
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
@ -503,19 +522,19 @@ 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 author = getOrCreateUser(event.pubKey.toHexKey())
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey)
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())
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
@ -526,7 +545,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++
@ -534,12 +555,12 @@ object LocalCache {
return
}
val channel = checkGetOrCreateChannel(event.channel) ?: return
val channel = checkGetOrCreateChannel(channelId) ?: return
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey.toHexKey())
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
@ -549,12 +570,11 @@ 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 }
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
@ -584,18 +604,21 @@ object LocalCache {
}
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id.toHexKey())
val note = getOrCreateNote(event.id)
// Already processed this event.
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 zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
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
@ -620,14 +643,15 @@ object LocalCache {
}
fun consume(event: LnZapRequestEvent) {
val note = getOrCreateNote(event.id.toHexKey())
val note = getOrCreateNote(event.id)
// Already processed this event.
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 author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@ -659,10 +683,15 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> {
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.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)
}
}
@ -746,7 +775,7 @@ object LocalCache {
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
users[it]?.notes ?: emptySet()
(users[it]?.notes ?: emptySet())
}.flatten()
account.hiddenUsers.forEach {
@ -799,7 +828,7 @@ class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(Local
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(500)
delay(50)
refresh()
} finally {
withContext(NonCancellable) {

View File

@ -2,7 +2,12 @@ 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
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Relay
@ -22,11 +27,19 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
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
@ -49,13 +62,24 @@ class Note(val idHex: String) {
var relays = setOf<String>()
private set
var channel: Channel? = null
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 { it.id }
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address() = (event as? LongTextNoteEvent)?.address()
open fun createdAt() = event?.createdAt
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event
@ -77,14 +101,14 @@ class Note(val idHex: String) {
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = 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<Note, Int> = mutableMapOf()): Int {
@ -223,10 +247,26 @@ 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)
}
fun directlyCiteUsersHex(): Set<HexKey> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<User>()
@ -270,7 +310,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<Note> {
@ -343,12 +383,21 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(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)
}
}
}

View File

@ -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<Note> {
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<Note>()

View File

@ -18,8 +18,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import nostr.postr.toNpub
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@ -37,6 +37,7 @@ class User(val pubkeyHex: String) {
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
@ -157,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
}
@ -289,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
}
@ -342,6 +343,7 @@ data class RelayInfo (
data class Chatroom(var roomMessages: Set<Note>)
class UserMetadata {
var name: String? = null
var username: String? = null

View File

@ -2,62 +2,127 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.nio.ByteBuffer
import java.nio.ByteOrder
class Nip19 {
enum class Type {
USER, NOTE
USER, NOTE, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String)
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:")
val key = uri?.removePrefix("nostr:") ?: return null
if (key != null) {
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return Return(Type.USER, bytes.toHexKey())
}
if (key.startsWith("note")) {
return Return(Type.NOTE, bytes.toHexKey())
}
if (key.startsWith("nprofile")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.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()
if (hex != null)
return Return(Type.USER, hex)
}
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
}
} catch (e: Throwable) {
println("Trying to Decode NIP19: ${uri}")
e.printStackTrace()
println("Issue trying to Decode NIP19 ${uri}: ${e.message}")
}
return null
}
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
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())
}
result.get(t)?.add(v)
}
return result
private fun npub(bytes: ByteArray): Return {
return Return(Type.USER, bytes.toHexKey())
}
}
private fun note(bytes: ByteArray): Return {
return Return(Type.NOTE, bytes.toHexKey());
}
private fun nprofile(bytes: ByteArray): Return? {
val hex = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
return Return(Type.USER, hex)
}
private fun nevent(bytes: ByteArray): Return? {
val hex = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
return Return(Type.USER, hex)
}
private fun nrelay(bytes: ByteArray): Return? {
val relayUrl = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
return Return(Type.RELAY, relayUrl)
}
private fun naddr(bytes: ByteArray): Return? {
val tlv = parseTLV(bytes)
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
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) }
return Return(Type.ADDRESS, "$kind:$author:$d")
}
}
// Classes should start with an uppercase letter in kotlin
enum class NIP19TLVTypes(val id: Byte) {
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>> {
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
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[t] = mutableListOf()
}
result[t]?.add(v)
}
return result
}

View File

@ -9,9 +9,9 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrAccountDataSource: NostrDataSource("AccountData") {
lateinit var account: Account

View File

@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
lateinit var account: Account

View File

@ -7,7 +7,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account

View File

@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
@ -15,7 +16,6 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.service.relays.hasValidSignature
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
@ -26,13 +26,13 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
@ -62,37 +62,32 @@ abstract class NostrDataSource(val debugName: String) {
try {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event, relay)
is RecommendRelayEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
is ChannelMetadataEvent -> LocalCache.consume(event)
is ChannelMuteUserEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is DeletionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> {
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
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))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
LnZapEvent.kind -> {
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
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))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
LongTextNoteEvent.kind -> LocalCache.consume(LongTextNoteEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {

View File

@ -1,16 +1,17 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)

View File

@ -9,7 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
lateinit var account: Account

View File

@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {

View File

@ -1,11 +1,12 @@
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
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@ -13,13 +14,42 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
if (addressesToWatch.isEmpty()) {
return null
}
val now = Date().time / 1000
return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.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<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val reactionsToWatch = eventsToWatch
if (reactionsToWatch.isEmpty()) {
return null
@ -35,7 +65,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.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("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@ -46,11 +76,9 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
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 }
@ -69,7 +97,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
@ -80,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.
@ -90,17 +118,28 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
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()
}
}

View File

@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()

View File

@ -3,12 +3,13 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
var user: User? = null
@ -38,7 +39,7 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

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("ATag", "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) {
Log.w( "ATag", "Issue trying to Decode NIP19 ${this}: ${e.message}")
//e.printStackTrace()
}
return null
}
}
}

View File

@ -1,28 +1,24 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class ChannelCreateEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): 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 {
@ -39,11 +35,11 @@ class ChannelCreateEvent (
""
}
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = emptyList<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}

View File

@ -1,29 +1,26 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
class ChannelHideMessageEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val eventsToHide: List<String>
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
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {
listOf("e", it)
@ -31,7 +28,7 @@ class ChannelHideMessageEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,33 +1,29 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
class ChannelMessageEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
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
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("e", channel, "", "root")
)
@ -40,7 +36,7 @@ class ChannelMessageEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,32 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class ChannelMetadataEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): 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
@ -38,11 +33,11 @@ class ChannelMetadataEvent (
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,29 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
class ChannelMuteUserEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val usersToMute: List<String>
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
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
usersToMute?.map {
listOf("p", it)
@ -31,7 +29,7 @@ class ChannelMuteUserEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -0,0 +1,59 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
data class Contact(val pubKeyHex: String, val relayUri: String?)
class ContactListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun follows() = try {
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
null
}
fun relayUse() = try {
if (content.isNotEmpty())
gson.fromJson(content, object: TypeToken<Map<String, ReadWrite>>() {}.type)
else
null
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
null
}
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null)
gson.toJson(relayUse)
else
""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = follows.map {
if (it.relayUri != null)
listOf("p", it.pubKeyHex, it.relayUri)
else
listOf("p", it.pubKeyHex)
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
data class ReadWrite(val read: Boolean, val write: Boolean)
}

View File

@ -0,0 +1,30 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
class DeletionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun deleteEvents() = tags.map { it[1] }
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = deleteEvents.map { listOf("e", it) }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -0,0 +1,197 @@
package com.vitorpamplona.amethyst.service.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import java.lang.reflect.Type
import java.security.MessageDigest
import java.util.Date
import nostr.postr.Utils
import nostr.postr.toHex
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: HexKey
) {
fun toJson(): String = gson.toJson(this)
fun generateId(): String {
val rawEvent = listOf(
0,
pubKey,
createdAt,
kind,
tags,
content
)
val rawEventJson = gson.toJson(rawEvent)
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
fun checkSignature() {
if (!id.contentEquals(generateId())) {
throw Exception(
"""|Unexpected ID.
| Event: ${toJson()}
| Actual ID: ${id}
| Generated: ${generateId()}""".trimIndent()
)
}
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
throw Exception("""Bad signature!""")
}
}
fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
if (!Secp256k1.get().verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
return false
}
return true
}
class EventDeserializer : JsonDeserializer<Event> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Event {
val jsonObject = json.asJsonObject
return Event(
id = jsonObject.get("id").asString,
pubKey = jsonObject.get("pubkey").asString,
createdAt = jsonObject.get("created_at").asLong,
kind = jsonObject.get("kind").asInt,
tags = jsonObject.get("tags").asJsonArray.map {
it.asJsonArray.map { s -> s.asString }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
)
}
}
class EventSerializer : JsonSerializer<Event> {
override fun serialize(
src: Event,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("id", src.id)
addProperty("pubkey", src.pubKey)
addProperty("created_at", src.createdAt)
addProperty("kind", src.kind)
add("tags", JsonArray().also { jsonTags ->
src.tags.forEach { tag ->
jsonTags.add(JsonArray().also { jsonTagElement ->
tag.forEach { tagElement ->
jsonTagElement.add(tagElement)
}
})
}
})
addProperty("content", src.content)
addProperty("sig", src.sig)
}
}
}
class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ByteArray = Hex.decode(json.asString)
}
class ByteArraySerializer : JsonSerializer<ByteArray> {
override fun serialize(
src: ByteArray,
typeOfSrc: Type?,
context: JsonSerializationContext?
) = JsonPrimitive(src.toHex())
}
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> this
}
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
val rawEvent = listOf(
0,
pubKey,
createdAt,
kind,
tags,
content
)
val rawEventJson = gson.toJson(rawEvent)
return sha256.digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey).toHexKey()
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
}
}
}

View File

@ -1,44 +1,37 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
import nostr.postr.events.Event
class LnZapEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
@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 {

View File

@ -1,53 +1,53 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapRequestEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
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
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf(
listOf("e", originalNote.id.toHex()),
listOf("p", originalNote.pubKey.toHex()),
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id),
listOf("p", originalNote.pubKey),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,46 +1,40 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
class LongTextNoteEvent(
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
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<String>
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, 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()
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 {
const val kind = 30023
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
@ -50,7 +44,7 @@ class LongTextNoteEvent(
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return LongTextNoteEvent(id, pubKey, createdAt, tags, msg, sig)
return LongTextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
}

View File

@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.Gson
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?)
class MetadataEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java)
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e)
null
}
companion object {
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), privateKey, createdAt = createdAt)
}
fun create(contactMetaData: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
val content = contactMetaData
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -0,0 +1,86 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import java.util.Date
import nostr.postr.Utils
import nostr.postr.toHex
class PrivateDmEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
/**
* This may or may not be the actual recipient's pub key. The event is intended to look like a
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
val retVal = Utils.decrypt(content, sharedSecret)
if (retVal.startsWith(nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 4
const val nip18Advertisement = "[//]: # (nip18)\n"
fun create(
recipientPubKey: ByteArray,
msg: String,
replyTos: List<String>? = null, mentions: List<String>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
privateKey,
recipientPubKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
publishedRecipientPubKey?.let {
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
}
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,26 +1,23 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ReactionEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val originalPost: List<String>
@Transient val originalAuthor: List<String>
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
@ -34,11 +31,16 @@ class ReactionEvent (
}
fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf( listOf("e", originalNote.id), listOf("p", originalNote.pubKey))
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", originalNote.address().toTag()) )
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id, pubKey, createdAt, tags, content, sig)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -0,0 +1,37 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.net.URI
import java.util.Date
import nostr.postr.Utils
class RecommendRelayEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey,
val lenient: Boolean = false
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun relay() = if (lenient)
URI.create(content.trim())
else
URI.create(content)
companion object {
const val kind = 2
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
val content = relay.toString()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendRelayEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -1,28 +1,25 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
class ReportEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportedPost: List<ReportedKey>
@Transient val reportedAuthor: List<ReportedKey>
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,40 +27,48 @@ 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
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportPostTag = listOf("e", reportedPost.id.toHex(), type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex(), type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id, type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedPost.pubKey, type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", reportedPost.address().toTag()) )
}
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
@ -71,11 +76,11 @@ class ReportEvent (
val reportAuthorTag = listOf("p", reportedUser, type.name.toLowerCase())
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags:List<List<String>> = listOf(reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}

View File

@ -1,33 +1,30 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class RepostEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
@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 {
@ -36,14 +33,19 @@ class RepostEvent (
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id.toHex())
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
val replyToPost = listOf("e", boostedPost.id)
val replyToAuthor = listOf("p", boostedPost.pubKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) {
tags = tags + listOf( listOf("a", boostedPost.address().toTag()) )
}
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RepostEvent(id, pubKey, createdAt, tags, content, sig)
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
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<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@ -4,7 +4,7 @@ import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
/**
* The Nostr Client manages multiple personae the user may switch between. Events are received and
@ -38,9 +38,7 @@ object Client: RelayPool.Listener {
if (relays.size != newRelayConfig.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url }
if (newRelayInfo == null) return false
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
}

View File

@ -1,16 +0,0 @@
package com.vitorpamplona.amethyst.service.relays
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.events.Event
import nostr.postr.events.generateId
fun Event.hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
if (!Secp256k1.get().verifySchnorr(sig, id, pubKey)) {
return false
}
return true
}

View File

@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import java.util.Date
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@ -54,7 +54,7 @@ class Relay(
if (socket != null) return
try {
val request = Request.Builder().header("Origin", "amethyst.social").url(url.trim()).build()
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {

View File

@ -5,7 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.

View File

@ -21,7 +21,6 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import java.util.Locale
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {

View File

@ -9,12 +9,11 @@ import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import java.io.File
import com.vitorpamplona.amethyst.R
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
@ -50,7 +49,7 @@ object ImageSaver {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
context.getString(R.string.can_t_find_out_the_content_type)
"Can't find out the content type"
}
saveContentQ(
@ -58,7 +57,6 @@ object ImageSaver {
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
context = context
)
} else {
saveContentDefault(
@ -82,7 +80,6 @@ object ImageSaver {
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
context: Context
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
@ -96,13 +93,13 @@ object ImageSaver {
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
context.getString(R.string.can_t_insert_the_new_content)
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
context.getString(R.string.can_t_open_the_content_output_stream)
"Can't open the content output stream"
}
outputStream.use {

View File

@ -1,23 +1,14 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType
import com.vitorpamplona.amethyst.R
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
object ImageUploader {
fun uploadImage(
@ -25,7 +16,6 @@ object ImageUploader {
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit,
context : Context
) {
val contentType = contentResolver.getType(uri)
@ -43,7 +33,7 @@ object ImageUploader {
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
context.getString(R.string.can_t_open_the_image_input_stream)
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
@ -66,7 +56,7 @@ object ImageUploader {
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
checkNotNull(url) {
context.getString(R.string.there_must_be_an_uploaded_image_url_in_the_response)
"There must be an uploaded image URL in the response"
}
onSuccess(url)

View File

@ -41,7 +41,7 @@ import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@OptIn(ExperimentalComposeUiApi::class)

View File

@ -9,12 +9,7 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.flow.MutableSharedFlow
@ -46,7 +41,6 @@ class NewPostViewModel: ViewModel() {
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
@ -68,12 +62,12 @@ class NewPostViewModel: ViewModel() {
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
@ -108,8 +102,8 @@ class NewPostViewModel: ViewModel() {
}.joinToString(" ")
}.joinToString("\n")
if (originalNote?.channel != null) {
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
if (originalNote?.channel() != null) {
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
}
@ -134,10 +128,9 @@ class NewPostViewModel: ViewModel() {
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit(context.getString(R.string.failed_to_upload_the_image))
imageUploadingError.emit("Failed to upload the image")
}
},
context = context
}
)
}

View File

@ -42,10 +42,10 @@ fun ClickableRoute(
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel != null) {
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {

View File

@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -22,11 +23,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
@Composable
fun ExpandableRichTextViewer(
@ -43,7 +46,17 @@ fun ExpandableRichTextViewer(
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, modifier, tags, backgroundColor, accountViewModel, navController)
//CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
RichTextViewer(
text,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
//}
if (content.length > 350 && !showFullText) {
Row(

View File

@ -26,20 +26,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.Paragraph
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichText
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.currentRichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.halilibo.richtext.ui.string.RichTextString
import com.halilibo.richtext.ui.string.RichTextStringStyle
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Nip19
@ -81,20 +82,47 @@ fun RichTextViewer(
navController: NavController,
) {
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
),
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
Column(modifier = modifier.animateContentSize()) {
if (content.startsWith("# ") || content.contains("##") || content.contains("```")) {
var richTextStyle by remember { mutableStateOf(RichTextStyle().resolveDefaults()) }
if ( content.startsWith("# ")
|| content.contains("##")
|| content.contains("**")
|| content.contains("__")
|| content.contains("```")
) {
MaterialRichText(
style = RichTextStyle().resolveDefaults().copy(
stringStyle = richTextStyle.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
)
)
),
style = myMarkDownStyle,
) {
Markdown(
content = content,
@ -105,8 +133,8 @@ fun RichTextViewer(
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ');
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
@ -164,6 +192,10 @@ fun RichTextViewer(
}
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true)

View File

@ -183,7 +183,6 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
// TODO : Rashed translate this
Text(
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
stringResource(

View File

@ -19,10 +19,16 @@ import kotlinx.coroutines.withContext
@Composable
fun UrlPreview(url: String, urlText: String) {
val default = UrlCachedPreviewer.cache[url]?.let { UrlPreviewState.Loaded(it) } ?: UrlPreviewState.Loading
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.url == url)
UrlPreviewState.Loaded(it)
else
UrlPreviewState.Empty
} ?: UrlPreviewState.Loading
var context = LocalContext.current
var urlPreviewState by remember { mutableStateOf(default) }
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {

View File

@ -16,6 +16,6 @@ object ChannelFeedFilter: FeedFilter<Note>() {
// returns the last Note of each user.
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()
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 {
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()
}
}

View File

@ -17,13 +17,13 @@ object ChatroomListNewFeedFilter: FeedFilter<Note>() {
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()
}
}

View File

@ -4,18 +4,25 @@ 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 nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
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()
}

View File

@ -4,7 +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.RepostEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
@ -20,7 +20,7 @@ object HomeConversationsFeedFilter: FeedFilter<Note>() {
&& it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true
&& !it.isNewThread()
}
.sortedBy { it.event?.createdAt }
.sortedBy { it.createdAt() }
.reversed()
}
}

View File

@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
@ -13,7 +13,7 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
override fun feed(): List<Note> {
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<Note>() {
&& 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()
}
}

View File

@ -7,7 +7,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NotificationFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
@ -63,7 +63,7 @@ object NotificationFeedFilter: FeedFilter<Note>() {
)
}
.sortedBy { it.event?.createdAt }
.sortedBy { it.createdAt() }
.reversed()
}
}

View File

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

View File

@ -15,9 +15,11 @@ object UserProfileNewThreadFeedFilter: FeedFilter<Note>() {
}
override fun feed(): List<Note> {
return user?.notes
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()
}

View File

@ -12,6 +12,6 @@ object UserProfileReportsFeedFilter: FeedFilter<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

@ -7,12 +7,13 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
@ -36,8 +37,8 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontWeight.Companion.W500
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@ -45,14 +46,14 @@ import androidx.navigation.NavHostController
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
@Composable
fun DrawerContent(navController: NavHostController,
@ -88,7 +89,8 @@ fun DrawerContent(navController: NavHostController,
modifier = Modifier
.fillMaxWidth()
.weight(1F),
accountStateViewModel
accountStateViewModel,
account,
)
BottomContent(account.userProfile(), scaffoldState, navController)
@ -155,38 +157,44 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
if (accountUser.bestDisplayName() != null) {
Text(
accountUser.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}),
modifier = Modifier
.padding(top = 7.dp)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
if (accountUser.bestUsername() != null) {
Text(" @${accountUser.bestUsername()}", color = Color.LightGray,
modifier = Modifier.padding(top = 15.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
modifier = Modifier
.padding(top = 15.dp)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
}
Row(modifier = Modifier.padding(top = 15.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Row(modifier = Modifier
.padding(top = 15.dp)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Row() {
Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.following))
@ -206,66 +214,84 @@ fun ListContent(
navController: NavHostController,
scaffoldState: ScaffoldState,
modifier: Modifier,
accountViewModel: AccountStateViewModel
accountViewModel: AccountStateViewModel,
account: Account,
) {
val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) }
Column(modifier = modifier) {
LazyColumn() {
item {
if (accountUser != null)
NavigationRow(navController,
scaffoldState,
"User/${accountUser.pubkeyHex}",
Route.Profile.icon,
stringResource(R.string.profile)
)
Column(modifier = modifier.fillMaxHeight()) {
if (accountUser != null)
NavigationRow(
title = stringResource(R.string.profile),
icon = Route.Profile.icon,
tint = MaterialTheme.colors.primary,
navController = navController,
scaffoldState = scaffoldState,
route = "User/${accountUser.pubkeyHex}",
)
Divider(
modifier = Modifier.padding(bottom = 15.dp),
thickness = 0.25.dp
)
Column(modifier = modifier.padding(horizontal = 25.dp)) {
Row(modifier = Modifier.clickable(onClick = {
navController.navigate(Route.Filters.route)
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Text(
text = stringResource(R.string.security_filters),
fontSize = 18.sp,
fontWeight = W500
)
}
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
Text(
text = stringResource(R.string.log_out),
modifier = Modifier.padding(vertical = 15.dp),
fontSize = 18.sp,
fontWeight = W500
)
}
}
}
}
Divider(thickness = 0.25.dp)
NavigationRow(
title = stringResource(R.string.security_filters),
icon = Route.Filters.icon,
tint = MaterialTheme.colors.onBackground,
navController = navController,
scaffoldState = scaffoldState,
route = Route.Filters.route,
)
Divider(thickness = 0.25.dp)
IconRow(
title = "Backup Keys",
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = { backupDialogOpen = true }
)
Spacer(modifier = Modifier.weight(1f))
IconRow(
"Logout",
R.drawable.ic_logout,
MaterialTheme.colors.onBackground,
onClick = { accountViewModel.logOff() }
)
}
if (backupDialogOpen) {
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
}
}
@Composable
fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: String, icon: Int, title: String) {
fun NavigationRow(
title: String,
icon: Int,
tint: Color,
navController: NavHostController,
scaffoldState: ScaffoldState,
route: String,
) {
val coroutineScope = rememberCoroutineScope()
val currentRoute = currentRoute(navController)
IconRow(title, icon, tint, onClick = {
if (currentRoute != route) {
navController.navigate(route)
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
}
@Composable
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) {
Row(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = {
if (currentRoute != route) {
navController.navigate(route)
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
@ -276,7 +302,7 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
Icon(
painter = painterResource(icon), null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colors.primary
tint = tint
)
Text(
modifier = Modifier.padding(start = 16.dp),

View File

@ -53,7 +53,7 @@ sealed class Route(
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}
)
object Filters : Route("Filters", R.drawable.ic_dm,
object Filters : Route("Filters", R.drawable.ic_security,
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }}
)
@ -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
}

View File

@ -72,7 +72,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
launchSingleTop = true
}
} else {
note.channel?.let {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}

View File

@ -63,11 +63,11 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
} else if (note.channel() != null) {
val authorState by note.author!!.live().metadata.observeAsState()
val author = authorState?.user
val channelState by note.channel!!.live.observeAsState()
val channelState by note.channel()!!.live.observeAsState()
val channel = channelState?.channel
val noteEvent = note.event
@ -83,8 +83,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
var hasNewMessages by remember { mutableStateOf<Boolean>(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}") })

View File

@ -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
)

View File

@ -72,7 +72,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
launchSingleTop = true
}
} else {
note.channel?.let {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}

View File

@ -78,7 +78,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, r
launchSingleTop = true
}
} else {
note.channel?.let {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}

View File

@ -35,9 +35,12 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
@ -50,8 +53,9 @@ import com.vitorpamplona.amethyst.ui.components.UrlPreviewCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlin.time.ExperimentalTime
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -84,6 +88,7 @@ fun NoteCompose(
var moreActionsExpanded by remember { mutableStateOf(false) }
val noteEvent = note?.event
val baseChannel = note?.channel()
if (noteEvent == null) {
BlankNote(modifier.combinedClickable(
@ -99,6 +104,8 @@ fun NoteCompose(
navController,
onClick = { showHiddenNote = true }
)
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
@ -106,7 +113,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
@ -133,9 +140,11 @@ fun NoteCompose(
launchSingleTop = true
}
} else {
note.channel?.let {
navController.navigate("Channel/${it.idHex}")
}
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
@ -175,7 +184,6 @@ fun NoteCompose(
}
// boosted picture
val baseChannel = note.channel
if (noteEvent is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
@ -233,14 +241,14 @@ fun NoteCompose(
if (noteEvent is RepostEvent) {
Text(
" boosted",
" ${stringResource(id = R.string.boosted)}",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Text(
timeAgo(noteEvent.createdAt, context = context),
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
@ -293,7 +301,7 @@ fun NoteCompose(
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
val sortedMentions = note.mentions?.toSet()?.sortedBy { account.userProfile().isFollowing(it) }
note.channel?.let {
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
}
@ -321,7 +329,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)
@ -342,50 +350,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)
@ -428,6 +393,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()
@ -628,6 +643,18 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()
!!.isFollowing(note.author!!)) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
); onDismiss()
}) {
Text(stringResource(R.string.follow))
}
Divider()
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text(stringResource(R.string.copy_text))
}

View File

@ -74,7 +74,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
launchSingleTop = true
}
} else {
note.channel?.let {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}

View File

@ -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<Note>): 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<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 {
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() {
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<Note>, val likeEvents:
}
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 {
return createdAt

View File

@ -129,7 +129,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>): ViewModel() {
var handlerWaiting = AtomicBoolean()
private fun invalidateData() {
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Default)

View File

@ -30,6 +30,7 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
@Composable
fun FeedView(

View File

@ -63,9 +63,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>): ViewModel() {
val oldNotesState = feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
//if (notes != oldNotesState.feed.value) {
if (notes != oldNotesState.feed.value) {
updateFeed(notes)
//}
}
} else {
updateFeed(notes)
}
@ -95,7 +95,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>): ViewModel() {
scope.launch {
try {
delay(50)
refresh()
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)

View File

@ -38,7 +38,9 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>): Vie
val oldNotesState = feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
updateFeed(notes)
if (notes != oldNotesState.feed.value) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}

View File

@ -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
)
@ -268,21 +268,21 @@ fun NoteMaster(baseNote: Note,
}
if (noteEvent is LongTextNoteEvent) {
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
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,9 +293,12 @@ fun NoteMaster(baseNote: Note,
)
}
noteEvent.summary?.let {
noteEvent.summary()?.let {
Text(
text = it
text = it,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
)
}
}

View File

@ -42,7 +42,9 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>): ViewModel() {
val oldNotesState = feedContent.value
if (oldNotesState is UserFeedState.Loaded) {
// Using size as a proxy for has changed.
updateFeed(notes)
if (notes != oldNotesState.feed.value) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}

View File

@ -0,0 +1,123 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import kotlinx.coroutines.launch
import nostr.postr.toNsec
@Composable
fun AccountBackupDialog(account: Account, onClose: () -> Unit) {
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
MaterialRichText(
style = RichTextStyle().resolveDefaults(),
) {
Markdown(
content = stringResource(R.string.account_backup_tips_md),
)
}
Spacer(modifier = Modifier.height(15.dp))
NSecCopyButton(account)
}
}
}
}
}
@Composable
private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.loggedIn.privKey?.let {
clipboardManager.setText(AnnotatedString(it.toNsec()))
scope.launch {
Toast.makeText(
context,
context.getString(R.string.secret_key_copied_to_clipboard),
Toast.LENGTH_SHORT
).show()
}
}
},
shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup)
)
Text("Copy Secret Key", color = MaterialTheme.colors.onPrimary)
}
}

View File

@ -113,4 +113,7 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.prefer(source, target, preference)
}
fun follow(user: User) {
account.follow(user)
}
}

View File

@ -121,7 +121,6 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
Column(Modifier.fillMaxHeight()) {
ChannelHeader(
channel, account,
accountStateViewModel = accountStateViewModel,
navController = navController
)
@ -193,7 +192,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
trailingIcon = {
PostButton(
onPost = {
account.sendChannelMeesage(newPost.value.text, channel.idHex, replyTo.value, null)
account.sendChannelMessage(newPost.value.text, channel.idHex, replyTo.value, null)
newPost.value = TextFieldValue("")
replyTo.value = null
feedViewModel.refresh() // Don't wait a full second before updating
@ -213,7 +212,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
}
@Composable
fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: AccountStateViewModel, navController: NavController) {
fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavController) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return

View File

@ -131,7 +131,22 @@ fun TabKnown(
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.invalidateData()
feedViewModel.refresh()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.refresh()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
@ -157,7 +172,22 @@ fun TabNew(
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.invalidateData() // refresh view
feedViewModel.refresh()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.resetFilters()
feedViewModel.refresh()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {

View File

@ -79,7 +79,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(userId) {
feedViewModel.invalidateData()
feedViewModel.refresh()
}
DisposableEffect(userId) {
@ -87,7 +87,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
feedViewModel.refresh()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")

View File

@ -27,6 +27,7 @@ import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@ -54,8 +55,24 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
LaunchedEffect(accountViewModel) {
NostrHomeDataSource.resetFilters()
feedViewModel.invalidateData()
feedViewModelReplies.invalidateData()
feedViewModel.refresh()
feedViewModelReplies.refresh()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrHomeDataSource.resetFilters()
feedViewModel.refresh()
feedViewModelReplies.refresh()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {

View File

@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
@ -28,6 +32,20 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
feedViewModel.refresh()
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { source, event ->
if (event == Lifecycle.Event.ON_RESUME) {
feedViewModel.refresh()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)

View File

@ -10,7 +10,6 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
@ -78,7 +77,6 @@ import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import nostr.postr.toNsec
@OptIn(ExperimentalPagerApi::class)
@Composable
@ -337,10 +335,6 @@ private fun ProfileHeader(
.padding(bottom = 3.dp)) {
MessageButton(baseUser, navController)
if (accountUser == baseUser && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(baseUser)
if (accountUser == baseUser) {
@ -637,40 +631,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: Nav
}
}
@Composable
private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Key,
contentDescription = stringResource(R.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup)
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) }; popupExpanded = false }) {
Text(stringResource(R.string.copy_private_key_to_the_clipboard))
}
}
}
}
@Composable
private fun NPubCopyButton(

View File

@ -86,10 +86,8 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
delay(500)
feedViewModel.invalidateData()
LaunchedEffect(accountViewModel) {
feedViewModel.refresh()
}
DisposableEffect(accountViewModel) {
@ -97,7 +95,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
feedViewModel.invalidateData()
feedViewModel.refresh()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M21,10h-8.35C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H13l2,2l2,-2l2,2l4,-4.04L21,10zM7,15c-1.65,0 -3,-1.35 -3,-3c0,-1.65 1.35,-3 3,-3s3,1.35 3,3C10,13.65 8.65,15 7,15z"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12L21,5l-9,-4zM12,11.99h7c-0.53,4.12 -3.28,7.79 -7,8.94L12,12L5,12L5,6.3l7,-3.11v8.8z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Some files were not shown because too many files have changed in this diff Show More