mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-04-09 20:39:24 +02:00
Merge branch 'main' into feature/172-mark-all-read
This commit is contained in:
commit
42a27a6d8d
@ -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)
|
||||
|
@ -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 {
|
||||
|
2
app/proguard-rules.pro
vendored
2
app/proguard-rules.pro
vendored
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -74,6 +74,7 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
)
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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") {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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>()
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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) {
|
||||
|
@ -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.
|
||||
|
@ -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?) {
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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),
|
||||
|
@ -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
|
||||
}
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
@ -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}") })
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -113,4 +113,7 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
account.prefer(source, target, preference)
|
||||
}
|
||||
|
||||
fun follow(user: User) {
|
||||
account.follow(user)
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
@ -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()) {
|
||||
|
@ -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")
|
||||
|
@ -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()) {
|
||||
|
@ -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)
|
||||
|
@ -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(
|
||||
|
@ -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")
|
||||
|
11
app/src/main/res/drawable-anydpi/ic_key.xml
Normal file
11
app/src/main/res/drawable-anydpi/ic_key.xml
Normal 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>
|
12
app/src/main/res/drawable-anydpi/ic_logout.xml
Normal file
12
app/src/main/res/drawable-anydpi/ic_logout.xml
Normal 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>
|
11
app/src/main/res/drawable-anydpi/ic_security.xml
Normal file
11
app/src/main/res/drawable-anydpi/ic_security.xml
Normal 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>
|
BIN
app/src/main/res/drawable-hdpi/ic_key.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_key.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 380 B |
BIN
app/src/main/res/drawable-hdpi/ic_logout.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_logout.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 326 B |
BIN
app/src/main/res/drawable-hdpi/ic_security.png
Normal file
BIN
app/src/main/res/drawable-hdpi/ic_security.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 510 B |
BIN
app/src/main/res/drawable-mdpi/ic_key.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_key.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 250 B |
BIN
app/src/main/res/drawable-mdpi/ic_logout.png
Normal file
BIN
app/src/main/res/drawable-mdpi/ic_logout.png
Normal file
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
Loading…
x
Reference in New Issue
Block a user