Support for NIP24- Trustless GiftWrapped Sealed Private Direct Messages and Small Private Groups

This commit is contained in:
Vitor Pamplona
2023-08-10 18:04:23 -04:00
parent 89266bc76f
commit ab2fff0194
63 changed files with 1982 additions and 410 deletions
@@ -64,6 +64,7 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
@@ -231,6 +232,7 @@ object LocalPreferences {
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
@@ -365,6 +367,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
@@ -431,6 +434,7 @@ object LocalPreferences {
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
@@ -72,6 +72,7 @@ class Account(
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP24WarningDialog: Boolean = false,
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int,
@@ -219,23 +220,57 @@ class Account(
return
}
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val event = ReactionEvent.create(emojiUrl, it, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event)
if (note.event is ChatMessageEvent) {
val event = note.event as ChatMessageEvent
val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList()
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val giftWraps = NIP24Factory().createReactionWithinGroup(
emojiUrl = emojiUrl,
originalNote = it,
to = users,
from = keyPair.privKey!!
)
broadcastPrivately(giftWraps)
}
return
}
return
}
}
note.event?.let {
val event = ReactionEvent.create(reaction, it, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event)
note.event?.let {
val giftWraps = NIP24Factory().createReactionWithinGroup(
content = reaction,
originalNote = it,
to = users,
from = keyPair.privKey!!
)
broadcastPrivately(giftWraps)
}
} else {
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val event = ReactionEvent.create(emojiUrl, it, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
return
}
}
note.event?.let {
val event = ReactionEvent.create(reaction, it, keyPair.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
}
@@ -834,14 +869,18 @@ class Account(
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash)
}
fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = toUser.pubkey(),
publishedRecipientPubKey = toUser.pubkey(),
recipientPubKey = toUser.hexToByteArray(),
publishedRecipientPubKey = toUser.hexToByteArray(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
@@ -856,6 +895,59 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun sendNIP24PrivateMessage(
message: String,
toUsers: List<HexKey>,
subject: String? = null,
replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
geohash: String? = null
) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvents = NIP24Factory().createMsgNIP24(
msg = message,
to = toUsers,
subject = subject,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
from = keyPair.privKey!!
)
broadcastPrivately(signedEvents)
}
fun broadcastPrivately(signedEvents: List<GiftWrapEvent>) {
signedEvents.forEach {
Client.send(it)
// Only keep in cache the GiftWrap for the account.
if (it.recipientPubKey() == keyPair.pubKey.toHexKey()) {
it.cachedGift(keyPair.privKey!!)?.let {
if (it is SealedGossipEvent) {
it.cachedGossip(keyPair.privKey!!)?.let {
LocalCache.justConsume(it, null)
}
} else {
LocalCache.justConsume(it, null)
}
}
LocalCache.consume(it, null)
}
}
}
fun sendCreateNewChannel(name: String, about: String, picture: String) {
if (!isWriteable()) return
@@ -1343,11 +1435,22 @@ class Account(
follow(channel)
}
fun unwrap(event: GiftWrapEvent): Event? {
if (!isWriteable()) return null
return event.cachedGift(keyPair.privKey!!)
}
fun unseal(event: SealedGossipEvent): Event? {
if (!isWriteable()) return null
return event.cachedGossip(keyPair.privKey!!)
}
fun decryptContent(note: Note): String? {
val privKey = keyPair.privKey
val event = note.event
return if (event is PrivateDmEvent && keyPair.privKey != null) {
event.plainContent(keyPair.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && keyPair.privKey != null) {
return if (event is PrivateDmEvent && privKey != null) {
event.plainContent(privKey, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
event?.content()
@@ -1487,7 +1590,12 @@ class Account(
}
}
fun isAllHidden(users: Set<HexKey>): Boolean {
return users.all { isHidden(it) }
}
fun isHidden(user: User) = isHidden(user.pubkeyHex)
fun isHidden(userHex: String): Boolean {
val blockList = getBlockList()
@@ -1566,6 +1674,11 @@ class Account(
saveable.invalidateData()
}
fun setHideNIP24WarningDialog() {
hideNIP24WarningDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
@@ -10,6 +10,8 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -1122,7 +1124,29 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet()
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
if (recipients.isNotEmpty()) {
recipients.forEach {
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
val authorGroup = if (groupMinusRecipient.isEmpty()) {
// note to self
ChatroomKey(persistentSetOf(it.pubkeyHex))
} else {
ChatroomKey(groupMinusRecipient.toImmutableSet())
}
it.addMessage(authorGroup, note)
}
}
refreshObservers(note)
}
@@ -1144,7 +1168,7 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: GiftWrapEvent, relay: Relay?) {
fun consume(event: GiftWrapEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1448,16 +1472,28 @@ object LocalCache {
}
fun verifyAndConsume(event: Event, relay: Relay?) {
if (justVerify(event)) {
justConsume(event, relay)
}
}
fun justVerify(event: Event): Boolean {
checkNotInMainThread()
if (!event.hasValidSignature()) {
return if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
false
} else {
true
}
}
fun justConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
try {
when (event) {
@@ -104,11 +104,13 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.replyTo = replyTo
if (this.event?.id() != event.id()) {
this.event = event
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.metadata?.invalidateData()
}
}
fun formattedDateTime(timestamp: Long): String {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
@@ -9,6 +10,7 @@ import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
@Stable
class RelayInformation(
val name: String?,
val description: String?,
@@ -39,6 +41,7 @@ class RelayInformation(
}
}
@Stable
class RelayInformationFee(
val amount: Int?,
val unit: String?,
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.CryptoUtils
object TimeUtils {
const val oneMinute = 60
const val fiveMinutes = 5 * oneMinute
@@ -14,4 +16,6 @@ object TimeUtils {
fun oneDayAgo() = now() - oneDay
fun eightHoursAgo() = now() - eightHours
fun oneWeekAgo() = now() - oneWeek
fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(oneWeek)
}
@@ -19,12 +19,19 @@ import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
data class ChatroomKey(
val users: ImmutableSet<HexKey>
)
@Stable
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
@@ -43,7 +50,7 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<User, Chatroom>()
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
@@ -55,6 +62,21 @@ class User(val pubkeyHex: String) {
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
val names = fullName.split(' ')
val firstName = if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
@@ -174,18 +196,31 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
return privateChatrooms[key] ?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
fun addMessage(room: ChatroomKey, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
fun addMessage(user: User, msg: Note) {
@@ -196,6 +231,10 @@ class User(val pubkeyHex: String) {
}
}
fun createChatroom(withKey: ChatroomKey) {
getOrCreatePrivateChatroom(withKey)
}
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
@@ -309,8 +348,8 @@ class User(val pubkeyHex: String) {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
@@ -373,8 +412,11 @@ data class RelayInfo(
var counter: Long
)
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
@@ -382,6 +424,13 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@@ -391,9 +440,18 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
@@ -13,8 +13,13 @@ import javax.crypto.spec.SecretKeySpec
object CryptoUtils {
private val secp256k1 = Secp256k1.get()
private val libSodium = SodiumAndroid()
private val random = SecureRandom()
fun randomInt(bound: Int): Int {
return random.nextInt(bound)
}
/**
* Provides a 32B "private key" aka random number
*/
@@ -50,11 +55,15 @@ object CryptoUtils {
secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33)
fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return encryptNIP04(msg, sharedSecret)
val encryptionInfo = encryptNIP04(msg, getSharedSecretNIP04(privateKey, pubKey))
return "${encryptionInfo.ciphertext}?iv=${encryptionInfo.nonce}"
}
fun encryptNIP04(msg: String, sharedSecret: ByteArray): String {
fun encryptNIP04Json(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
return encryptNIP04(msg, getSharedSecretNIP04(privateKey, pubKey))
}
fun encryptNIP04(msg: String, sharedSecret: ByteArray): EncryptedInfo {
val iv = ByteArray(16)
random.nextBytes(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
@@ -62,7 +71,7 @@ object CryptoUtils {
val ivBase64 = Base64.getEncoder().encodeToString(iv)
val encryptedMsg = cipher.doFinal(msg.toByteArray())
val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
return "$encryptedMsgBase64?iv=$ivBase64"
return EncryptedInfo(encryptedMsgBase64, ivBase64, Nip44Version.NIP04.versionCode)
}
fun decryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
@@ -70,10 +79,19 @@ object CryptoUtils {
return decryptNIP04(msg, sharedSecret)
}
fun decryptNIP04(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String {
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
return decryptNIP04(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret)
}
fun decryptNIP04(msg: String, sharedSecret: ByteArray): String {
val parts = msg.split("?iv=")
val iv = parts[1].run { Base64.getDecoder().decode(this) }
val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
return decryptNIP04(parts[0], parts[1], sharedSecret)
}
private fun decryptNIP04(cipher: String, nonce: String, sharedSecret: ByteArray): String {
val iv = Base64.getDecoder().decode(nonce)
val encryptedMsg = Base64.getDecoder().decode(cipher)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
return String(cipher.doFinal(encryptedMsg))
@@ -88,7 +106,8 @@ object CryptoUtils {
val nonce = ByteArray(24)
random.nextBytes(nonce)
val cipher = SodiumAndroid().cryptoStreamXChaCha20Xor(
val cipher = cryptoStreamXChaCha20Xor(
libSodium = libSodium,
messageBytes = msg.toByteArray(),
nonce = nonce,
key = Key.fromBytes(sharedSecret)
@@ -100,7 +119,7 @@ object CryptoUtils {
return EncryptedInfo(
ciphertext = cipherBase64,
nonce = nonceBase64,
v = Nip44Version.XChaCha20.versionCode
v = Nip44Version.NIP24.versionCode
)
}
@@ -110,7 +129,8 @@ object CryptoUtils {
}
fun decryptNIP24(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
return SodiumAndroid().cryptoStreamXChaCha20Xor(
return cryptoStreamXChaCha20Xor(
libSodium = libSodium,
messageBytes = Base64.getDecoder().decode(encryptedInfo.ciphertext),
nonce = Base64.getDecoder().decode(encryptedInfo.nonce),
key = Key.fromBytes(sharedSecret)
@@ -127,6 +147,6 @@ object CryptoUtils {
data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int)
enum class Nip44Version(val versionCode: Int) {
Reserved(0),
XChaCha20(1)
NIP04(0),
NIP24(1)
}
@@ -1,33 +1,41 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
private var withRoom: ChatroomKey? = null
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = user
private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
this.account = accountIn
this.withRoom = withRoom
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
authors = listOf(myPeer.pubkeyHex),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
kinds = listOf(PrivateDmEvent.kind),
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -35,8 +43,28 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
}
/* DOESN'T Load gift wraps here because it is already loaded on Chatroom List.
There is no way to filter for gifts only in this conversation.
fun createGiftWrapsToMeFilter(): TypedFilter? {
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(GiftWrapEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
null
}
}
*/
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
@@ -44,7 +72,8 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
tags = mapOf("p" to myPeer.users.map { it }),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -52,9 +81,40 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
}
val inandoutChannel = requestNewChannel()
override fun consume(event: Event, relay: Relay) {
if (this::account.isInitialized && LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
}
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
}
// Don't store sealed gossips to avoid rebroadcasting by mistake.
} else {
LocalCache.justConsume(event, relay)
}
}
}
val inandoutChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.typedFilters = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter()
).ifEmpty { null }
}
}
@@ -1,15 +1,19 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
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.Event
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
@@ -93,6 +97,32 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun consume(event: Event, relay: Relay) {
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
}
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
}
// Don't store sealed gossips to avoid rebroadcasting by mistake.
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.verifyAndConsume(event, relay)
consume(event, relay)
}
}
@@ -164,6 +164,10 @@ abstract class NostrDataSource(val debugName: String) {
}
}
open fun consume(event: Event, relay: Relay) {
LocalCache.verifyAndConsume(event, relay)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}
@@ -1,10 +1,16 @@
package com.vitorpamplona.amethyst.service
import com.goterl.lazysodium.Sodium
import com.goterl.lazysodium.SodiumAndroid
import com.goterl.lazysodium.utils.Key
fun Sodium.crypto_stream_xchacha20_xor_ic(
/**
* I initially extended these methods from the Sodium and SodiumAndroid classes
* But JNI doesn't like it. There is some native method overriding bug
* when using Kotlin extensions
**/
fun /*Sodium.*/crypto_stream_xchacha20_xor_ic(
libSodium: SodiumAndroid,
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
@@ -25,8 +31,8 @@ fun Sodium.crypto_stream_xchacha20_xor_ic(
val nonceChaCha = nonce.drop(16).toByteArray()
assert(nonceChaCha.size == 8)
crypto_core_hchacha20(k2, nonce, key, null)
return crypto_stream_chacha20_xor_ic(
libSodium.crypto_core_hchacha20(k2, nonce, key, null)
return libSodium.crypto_stream_chacha20_xor_ic(
cipher,
message,
messageLen,
@@ -36,17 +42,19 @@ fun Sodium.crypto_stream_xchacha20_xor_ic(
)
}
fun Sodium.crypto_stream_xchacha20_xor(
fun /*Sodium.*/crypto_stream_xchacha20_xor(
libSodium: SodiumAndroid,
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
nonce: ByteArray,
key: ByteArray
): Int {
return crypto_stream_xchacha20_xor_ic(cipher, message, messageLen, nonce, 0, key)
return crypto_stream_xchacha20_xor_ic(libSodium, cipher, message, messageLen, nonce, 0, key)
}
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor(
libSodium: SodiumAndroid,
cipher: ByteArray,
message: ByteArray,
messageLen: Long,
@@ -55,6 +63,7 @@ fun SodiumAndroid.cryptoStreamXChaCha20Xor(
): Boolean {
require(!(messageLen < 0 || messageLen > message.size)) { "messageLen out of bounds: $messageLen" }
return crypto_stream_xchacha20_xor(
libSodium,
cipher,
message,
messageLen,
@@ -63,13 +72,14 @@ fun SodiumAndroid.cryptoStreamXChaCha20Xor(
) == 0
}
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor(
libSodium: SodiumAndroid,
messageBytes: ByteArray,
nonce: ByteArray,
key: Key
): ByteArray? {
val mLen = messageBytes.size
val cipher = ByteArray(mLen)
val sucessful = cryptoStreamXChaCha20Xor(cipher, messageBytes, mLen.toLong(), nonce, key.asBytes)
val sucessful = cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes)
return if (sucessful) cipher else null
}
@@ -23,7 +23,8 @@ class AudioTrackEvent(
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
// fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
companion object {
@@ -20,7 +20,10 @@ open class BaseTextNoteEvent(
fun mentions() = taggedUsers()
open fun replyTos() = taggedEvents()
@Transient
private var citedUsersCache: Set<HexKey>? = null
@Transient
private var citedNotesCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import kotlinx.collections.immutable.toImmutableSet
@Immutable
class ChatMessageEvent(
@@ -14,22 +16,42 @@ class ChatMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
/**
* Recepients intended to receive this conversation
*/
private fun recipientsPubKey() = tags.mapNotNull {
fun recipientsPubKey() = tags.mapNotNull {
if (it.size > 1 && it[0] == "p") it[1] else null
}
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun talkingWith(oneSideHex: String): Set<HexKey> {
val listedPubKeys = recipientsPubKey()
return if (pubKey == oneSideHex) {
if (listedPubKeys.isEmpty()) {
// talking to myself
return setOf(pubKey)
} else {
listedPubKeys.minus(oneSideHex).toSet()
}
} else {
listedPubKeys.plus(pubKey).minus(oneSideHex).toSet()
}
}
override fun chatroomKey(toRemove: String): ChatroomKey {
return ChatroomKey(talkingWith(toRemove).toImmutableSet())
}
companion object {
const val kind = 14
fun create(
msg: String,
to: List<String>? = null,
subject: String? = null,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String? = null,
@@ -62,6 +84,9 @@ class ChatMessageEvent(
geohash?.let {
tags.add(listOf("g", it))
}
subject?.let {
tags.add(listOf("subject", it))
}
val pubKey = CryptoUtils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, ClassifiedsEvent.kind, tags, content)
@@ -70,3 +95,7 @@ class ChatMessageEvent(
}
}
}
interface ChatroomKeyable {
fun chatroomKey(toRemove: HexKey): ChatroomKey
}
@@ -24,6 +24,8 @@ class ContactListEvent(
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This function is only used by the user logged in
// But it is used all the time.
@delegate:Transient
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
try {
@@ -35,18 +37,22 @@ class ContactListEvent(
}.toSet()
}
@delegate:Transient
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
@delegate:Transient
val verifiedFollowGeohashSet: Set<String> by lazy {
unverifiedFollowGeohashSet().map { it.lowercase() }.toSet()
}
@delegate:Transient
val verifiedFollowCommunitySet: Set<String> by lazy {
unverifiedFollowAddressSet().toSet()
}
@delegate:Transient
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
@@ -66,6 +66,8 @@ open class Event(
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
@@ -210,12 +212,12 @@ open class Event(
): Event {
val jsonObject = json.asJsonObject
return Event(
id = jsonObject.get("id").asString,
pubKey = jsonObject.get("pubkey").asString,
id = jsonObject.get("id").asString.intern(),
pubKey = jsonObject.get("pubkey").asString.intern(),
createdAt = jsonObject.get("created_at").asLong,
kind = jsonObject.get("kind").asInt,
tags = jsonObject.get("tags").asJsonArray.map {
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString.intern() }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
@@ -223,6 +225,26 @@ open class Event(
}
}
private class GossipDeserializer : JsonDeserializer<Gossip> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Gossip {
val jsonObject = json.asJsonObject
return Gossip(
id = jsonObject.get("id")?.asString?.intern(),
pubKey = jsonObject.get("pubkey")?.asString?.intern(),
createdAt = jsonObject.get("created_at")?.asLong,
kind = jsonObject.get("kind")?.asInt,
tags = jsonObject.get("tags")?.asJsonArray?.mapNotNull {
it?.asJsonArray?.mapNotNull { s -> if (s?.isJsonNull != false) null else s.asString.intern() }
},
content = jsonObject.get("content")?.asString
)
}
}
private class EventSerializer : JsonSerializer<Event> {
override fun serialize(
src: Event,
@@ -254,6 +276,38 @@ open class Event(
}
}
private class GossipSerializer : JsonSerializer<Gossip> {
override fun serialize(
src: Gossip,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
src.id?.let { addProperty("id", it) }
src.pubKey?.let { addProperty("pubkey", it) }
src.createdAt?.let { addProperty("created_at", it) }
src.kind?.let { addProperty("kind", it) }
src.tags?.let {
add(
"tags",
JsonArray().also { jsonTags ->
it.forEach { tag ->
jsonTags.add(
JsonArray().also { jsonTagElement ->
tag.forEach { tagElement ->
jsonTagElement.add(tagElement)
}
}
)
}
}
)
}
src.content?.let { addProperty("content", it) }
}
}
}
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
@@ -275,6 +329,8 @@ open class Event(
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(Gossip::class.java, GossipSerializer())
.registerTypeAdapter(Gossip::class.java, GossipDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
@@ -13,34 +13,6 @@ class EventFactory {
content: String,
sig: String,
lenient: Boolean
): Event {
val internedTags = tags.map {
it.map {
it.intern()
}
}
return internedCreate(
id = id.intern(),
pubKey = pubKey.intern(),
createdAt = createdAt,
kind = kind,
tags = internedTags,
content = content,
sig = sig,
lenient = lenient
)
}
fun internedCreate(
id: String,
pubKey: String,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: String,
lenient: Boolean
) = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
@@ -56,6 +56,7 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun subject(): String?
fun zapraiserAmount(): Long?
fun taggedAddresses(): List<ATag>
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.Nip44Version
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
@@ -19,9 +20,10 @@ class GiftWrapEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
fun cachedGift(privKey: ByteArray): Event? {
val hex = privKey.toHexKey()
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
@@ -41,14 +43,13 @@ class GiftWrapEvent(
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson(content, EncryptedInfo::class.java)
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
return when (toDecrypt.v) {
Nip44Version.NIP04.versionCode -> CryptoUtils.decryptNIP04(toDecrypt, privKey, pubKey.hexToByteArray())
Nip44Version.NIP24.versionCode -> CryptoUtils.decryptNIP24(toDecrypt, privKey, pubKey.hexToByteArray())
else -> null
}
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
null
@@ -63,7 +64,7 @@ class GiftWrapEvent(
fun create(
event: Event,
recipientPubKey: HexKey,
createdAt: Long = TimeUtils.now()
createdAt: Long = TimeUtils.randomWithinAWeek()
): GiftWrapEvent {
val privateKey = CryptoUtils.privkeyCreate() // GiftWrap is always a random key
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, recipientPubKey.hexToByteArray())
@@ -8,14 +8,28 @@ class NIP24Factory {
fun createMsgNIP24(
msg: String,
to: List<HexKey>,
from: ByteArray
from: ByteArray,
subject: String? = null,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String? = null,
markAsSensitive: Boolean = false,
zapRaiserAmount: Long? = null,
geohash: String? = null
): List<GiftWrapEvent> {
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
val senderMessage = ChatMessageEvent.create(
msg = msg,
to = to,
privateKey = from
privateKey = from,
subject = subject,
replyTos = replyTos,
mentions = mentions,
zapReceiver = zapReceiver,
markAsSensitive = markAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash
)
return to.plus(senderPublicKey).map {
@@ -29,4 +43,46 @@ class NIP24Factory {
)
}
}
fun createReactionWithinGroup(content: String, originalNote: EventInterface, to: List<HexKey>, from: ByteArray): List<GiftWrapEvent> {
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
val senderReaction = ReactionEvent.create(
content,
originalNote,
from
)
return to.plus(senderPublicKey).map {
GiftWrapEvent.create(
event = SealedGossipEvent.create(
event = senderReaction,
encryptTo = it,
privateKey = from
),
recipientPubKey = it
)
}
}
fun createReactionWithinGroup(emojiUrl: EmojiUrl, originalNote: EventInterface, to: List<HexKey>, from: ByteArray): List<GiftWrapEvent> {
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
val senderReaction = ReactionEvent.create(
emojiUrl,
originalNote,
from
)
return to.plus(senderPublicKey).map {
GiftWrapEvent.create(
event = SealedGossipEvent.create(
event = senderReaction,
encryptTo = it,
privateKey = from
),
recipientPubKey = it
)
}
}
}
@@ -2,12 +2,14 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.HexValidator
import fr.acinq.secp256k1.Hex
import kotlinx.collections.immutable.persistentSetOf
@Immutable
class PrivateDmEvent(
@@ -17,7 +19,7 @@ class PrivateDmEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
/**
* 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
@@ -40,6 +42,10 @@ class PrivateDmEvent(
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
}
override fun chatroomKey(toRemove: String): ChatroomKey {
return ChatroomKey(persistentSetOf(talkingWith(toRemove)))
}
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.CryptoUtils
import com.vitorpamplona.amethyst.service.EncryptedInfo
import com.vitorpamplona.amethyst.service.Nip44Version
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
@@ -20,6 +21,7 @@ class SealedGossipEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
fun cachedGossip(privKey: ByteArray): Event? {
@@ -35,6 +37,7 @@ class SealedGossipEvent(
fun unseal(privKey: ByteArray): Gossip? = try {
plainContent(privKey)?.let { gson.fromJson(it, Gossip::class.java) }
} catch (e: Exception) {
Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e)
null
}
@@ -42,16 +45,15 @@ class SealedGossipEvent(
if (content.isBlank()) return null
return try {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
val toDecrypt = gson.fromJson(content, EncryptedInfo::class.java)
val toDecrypt = gson.fromJson<EncryptedInfo>(
content,
EncryptedInfo::class.java
)
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
return when (toDecrypt.v) {
Nip44Version.NIP04.versionCode -> CryptoUtils.decryptNIP04(toDecrypt, privKey, pubKey.hexToByteArray())
Nip44Version.NIP24.versionCode -> CryptoUtils.decryptNIP24(toDecrypt, privKey, pubKey.hexToByteArray())
else -> null
}
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message}")
Log.w("GossipEvent", "Error decrypting the message ${e.message}")
null
}
}
@@ -73,7 +75,7 @@ class SealedGossipEvent(
gossip: Gossip,
encryptTo: HexKey,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
createdAt: Long = TimeUtils.randomWithinAWeek()
): SealedGossipEvent {
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, encryptTo.hexToByteArray())
@@ -105,7 +107,7 @@ class Gossip(
fun mergeWith(event: SealedGossipEvent): Event {
val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey
val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt
val newKind = kind ?: 0
val newKind = kind ?: -1
val newTags = (tags ?: emptyList()).plus(event.tags)
val newContent = content ?: ""
val newID = id?.ifBlank { null } ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey()
@@ -19,8 +19,6 @@ class TextNoteEvent(
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1)
companion object {
@@ -5,32 +5,94 @@ import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.ChatMessageEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
fun unwrapAndConsume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
// adds to database
LocalCache.verifyAndConsume(event, null)
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
}
}
}
}
}
fun unwrapAndConsume(event: Event, account: Account): Event? {
if (account.keyPair.privKey == null) return null
return when (event) {
is GiftWrapEvent -> {
event.cachedGift(account.keyPair.privKey)?.let {
unwrapAndConsume(it, account)
}
}
is SealedGossipEvent -> {
event.cachedGossip(account.keyPair.privKey)?.let {
unwrapAndConsume(it, account)
}
}
else -> {
LocalCache.justConsume(event, null)
event
}
}
}
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
val event = unwrapAndConsume(giftWrap, account = acc)
if (event is ChatMessageEvent && acc.keyPair.privKey != null) {
val chatNote = LocalCache.notes[giftWrap.id] ?: return
val chatRoom = event.chatroomKey(acc.keyPair.privKey.toHexKey())
val followingKeySet = acc.followingKeySet()
val isKnownRoom = (
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
@@ -46,19 +108,21 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
it.pubkeyHex in followingKeySet || acc.userProfile()
.hasSentMessagesTo(it)
) && !acc.isHidden(it)
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
if (note.author in messagingWith) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
@@ -202,7 +202,9 @@ fun uriToRoute(uri: String?): String? {
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
nip19.author?.let {
"RoomByPubkey/$it"
}
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
@@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -300,6 +302,7 @@ private fun RenderSearchResults(
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
@@ -320,7 +323,12 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
scope.launch(Dispatchers.IO) {
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
accountViewModel.userProfile().createChatroom(withKey)
nav("Room/${withKey.hashCode()}")
}
searchBarViewModel.clear()
}
}
@@ -88,10 +88,12 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
@@ -110,17 +112,21 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewPostView(
onClose: () -> Unit,
baseReplyTo: Note? = null,
quote: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val account = remember(accountViewModel) { accountViewModel.account }
val postViewModel: NewPostViewModel = viewModel()
postViewModel.wantsDirectMessage = enableMessageInterface
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var showRelaysDialog by remember {
@@ -136,8 +142,6 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
delay(100)
focusRequester.requestFocus()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
@@ -254,74 +258,23 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.removeFromReplyList(it)
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colors.placeholderText
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
}
}
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
""
},
border = BorderStroke(
1.dp,
MaterialTheme.colors.placeholderText
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.placeholderText
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
}
PollField(postViewModel)
}
}
@@ -529,6 +482,89 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
""
},
border = BorderStroke(
1.dp,
MaterialTheme.colors.placeholderText
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.placeholderText
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun MessageField(
postViewModel: NewPostViewModel
) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(200)
focusRequester.requestFocus()
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colors.placeholderText
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
Column(
@@ -581,6 +617,86 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
}
}
@Composable
fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.messages_new_message_to),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.toUsers,
onValueChange = {
postViewModel.updateToUsers(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_to_caption),
color = MaterialTheme.colors.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colors.primary
),
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.messages_new_message_subject),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.subject,
onValueChange = {
postViewModel.updateSubject(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colors.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colors.primary
),
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Divider()
}
}
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel) {
Column(
@@ -33,9 +33,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
FORWARD_ZAPS,
TO_USERS
}
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var requiresNIP24: Boolean = false
var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
@@ -48,7 +56,12 @@ open class NewPostViewModel() : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: Boolean? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// DMs
var wantsDirectMessage by mutableStateOf(false)
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
@@ -91,6 +104,9 @@ open class NewPostViewModel() : ViewModel() {
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -143,6 +159,10 @@ open class NewPostViewModel() : ViewModel() {
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
toUsersTagger.run()
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
@@ -162,23 +182,7 @@ open class NewPostViewModel() : ViewModel() {
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
)
} else if (originalNote?.channelHex() != null) {
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else {
@@ -186,28 +190,71 @@ open class NewPostViewModel() : ViewModel() {
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
account?.sendNIP24PrivateMessage(
message = tagger.message,
toUsers = dmUsers.map { it.pubkeyHex },
subject = subject.text.ifBlank { null },
replyingTo = tagger.replyTos?.firstOrNull(),
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
} else {
account?.sendPrivateMessage(
message = tagger.message,
toUser = dmUsers.first().pubkeyHex,
replyingTo = originalNote,
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
}
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
val replyId = originalNote?.idHex
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
)
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
val replyId = originalNote?.idHex
account?.sendPost(
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
)
account?.sendPost(
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
)
}
}
cancel()
@@ -267,11 +314,16 @@ open class NewPostViewModel() : ViewModel() {
open fun cancel() {
message = TextFieldValue("")
toUsers = TextFieldValue("")
subject = TextFieldValue("")
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
wantsDirectMessage = false
wantsPoll = false
zapRecipients = mutableStateListOf<HexKey>()
pollOptions = newStateMapPollOptions()
@@ -316,7 +368,7 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
@@ -331,12 +383,37 @@ open class NewPostViewModel() : ViewModel() {
}
}
open fun updateToUsers(it: TextFieldValue) {
toUsers = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
}
open fun updateSubject(it: TextFieldValue) {
subject = it
}
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
@@ -357,7 +434,7 @@ open class NewPostViewModel() : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == true) {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -366,7 +443,7 @@ open class NewPostViewModel() : ViewModel() {
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else {
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -376,6 +453,15 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
toUsers = TextFieldValue(
toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
}
userSuggestionAnchor = null
@@ -389,7 +475,11 @@ open class NewPostViewModel() : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -498,6 +588,14 @@ open class NewPostViewModel() : ViewModel() {
location = null
locUtil = null
}
fun toggleNIP04And24() {
if (requiresNIP24) {
nip24 = true
} else {
nip24 = !nip24
}
}
}
enum class GeohashPrecision(val digits: Int) {
@@ -16,9 +16,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font12SP
@Composable
fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -26,7 +27,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
mutableStateOf(false)
}
var wantsToJoinChannelOrUser by remember {
var wantsToSendNewMessage by remember {
mutableStateOf(false)
}
@@ -38,23 +39,25 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel)
}
if (wantsToJoinChannelOrUser) {
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
if (wantsToSendNewMessage) {
NewPostView({ wantsToSendNewMessage = false }, enableMessageInterface = true, accountViewModel = accountViewModel, nav = nav)
// JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
}
Column() {
if (isOpen) {
OutlinedButton(
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
onClick = { wantsToSendNewMessage = true; isOpen = false },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(bottom = 3.dp)
) {
Text(
text = stringResource(R.string.channel_list_join_channel),
text = stringResource(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
fontSize = Font12SP
)
}
@@ -68,9 +71,10 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
contentPadding = PaddingValues(bottom = 3.dp)
) {
Text(
text = stringResource(R.string.channel_list_create_channel),
text = stringResource(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
fontSize = Font12SP
)
}
@@ -86,7 +90,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.new_channel),
contentDescription = stringResource(R.string.messages_create_public_chat),
modifier = Modifier.size(26.dp),
tint = Color.White
)
@@ -169,7 +169,11 @@ private fun DisplayNoteLink(
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
route = remember(noteState) {
(note.author?.pubkeyHex ?: nip19.hex).let {
"RoomByAuthor/$it"
}
},
nav = nav
)
} else if (channelHex != null) {
@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feedKey(): String {
return withUser.pubkeyHex
return withUser.hashCode().toString()
}
override fun feed(): List<Note> {
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -22,7 +23,10 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
(it.pubkeyHex in followingKeySet || me.hasSentMessagesTo(it)) && !account.isHidden(it)
(
privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
me.hasSentMessagesTo(it)
) && !account.isAllHidden(it.users)
}
val privateMessages = messagingWith.mapNotNull { it ->
@@ -75,7 +79,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
@@ -126,23 +130,25 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
return newRelevantPublicMessages
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<ChatroomKey, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
val newRelevantPrivateMessages = mutableMapOf<ChatroomKey, Note>()
newItems.filter { it.event is ChatroomKeyable }.forEach { newNote ->
val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
val room = account.userProfile().privateChatrooms[roomKey]
if (roomUserHex != null && (newNote.author?.pubkeyHex == me.pubkeyHex || roomUserHex in followingKeySet || me.hasSentMessagesTo(roomUser)) && !account.isHidden(roomUserHex)) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
if (roomKey != null && room != null) {
if ((newNote.author?.pubkeyHex == me.pubkeyHex || room.senderIntersects(followingKeySet) || me.hasSentMessagesTo(roomKey)) && !account.isAllHidden(roomKey.users)) {
val lastNote = newRelevantPrivateMessages.get(roomKey)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomKey, newNote)
}
} else {
newRelevantPrivateMessages.put(roomKey, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
@@ -1,8 +1,9 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
@@ -21,7 +22,8 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
it.pubkeyHex !in followingKeySet && !me.hasSentMessagesTo(it) && account.isAcceptable(it)
privateChatrooms[it]?.senderIntersects(followingKeySet) == false &&
!me.hasSentMessagesTo(it) && !account.isAllHidden(it.users)
}
val privateMessages = messagingWith.mapNotNull { it ->
@@ -52,7 +54,7 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val oldRoom = (oldNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
@@ -80,26 +82,26 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
}
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<ChatroomKey, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
val newRelevantPrivateMessages = mutableMapOf<ChatroomKey, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
val room = account.userProfile().privateChatrooms[roomKey]
if (roomUserHex != null &&
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
!account.isHidden(roomUserHex)
if (roomKey != null && room != null &&
(newNote.author?.pubkeyHex != me.pubkeyHex && room.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(roomKey)) &&
!account.isAllHidden(roomKey.users)
) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
val lastNote = newRelevantPrivateMessages.get(roomKey)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
newRelevantPrivateMessages.put(roomKey, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
newRelevantPrivateMessages.put(roomKey, newNote)
}
}
}
@@ -39,6 +39,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.event !is GiftWrapEvent &&
it.author !== loggedInUser &&
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen
@@ -213,7 +214,17 @@ fun AppNavigation(
Route.Room.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreen(
userId = it.arguments?.getString("id"),
roomId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
nav = nav
)
})
}
Route.RoomByAuthor.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreenByAuthor(
authorPubKeyHex = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
nav = nav
)
@@ -234,6 +245,7 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
LoadRedirectScreen(
eventId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.note.CommunityHeader
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -86,6 +85,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoom
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoomByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
@@ -152,10 +153,22 @@ private fun RenderTopRouteBar(
nav = nav
)
}
Route.Room.base -> LoadUser(baseUserHex = id) {
Route.RoomByAuthor.base -> LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) {
if (it != null) {
ChatroomHeader(
baseUser = it,
room = it,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 11.dp),
accountViewModel = accountViewModel,
nav = nav
)
} else {
Spacer(BottomTopHeight)
}
}
Route.Room.base -> LoadRoom(roomId = id, accountViewModel) {
if (it != null) {
ChatroomHeader(
room = it,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 11.dp),
accountViewModel = accountViewModel,
nav = nav
@@ -16,8 +16,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveNowFeedFilter
@@ -125,6 +125,12 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object RoomByAuthor : Route(
route = "RoomByAuthor/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Channel : Route(
route = "Channel/{id}",
icon = R.drawable.ic_moments,
@@ -271,9 +277,9 @@ object MessagesLatestItem : LatestItem() {
if (it == null) return false
val currentUser = account.userProfile().pubkeyHex
val room = (it.event as? PrivateDmEvent)?.talkingWith(currentUser)
val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser)
return if (room != null) {
val lastRead = account.loadLastRead("Room/$room")
val lastRead = account.loadLastRead("Room/${room.hashCode()}")
(it.createdAt() ?: 0) > lastRead
} else {
false
@@ -42,16 +42,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.HexKey
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.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
@@ -99,32 +103,32 @@ fun ChatroomComposeChannelOrUser(
if (channelHex != null) {
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
} else {
ChatroomDirectMessage(baseNote, accountViewModel, nav)
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
}
}
@Composable
private fun ChatroomDirectMessage(
private fun ChatroomPrivateMessages(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val userRoomHex by remember(baseNote) {
val userRoom by remember(baseNote) {
derivedStateOf {
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
(baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
}
}
userRoomHex?.let {
LoadUser(it) { baseUser ->
Crossfade(baseUser) { user ->
if (user != null) {
UserRoomCompose(baseNote, user, accountViewModel, nav)
} else {
Box(Modifier.height(Size75dp).fillMaxWidth()) {
// Makes sure just a max amount of objects are loaded.
}
}
Crossfade(userRoom) { room ->
if (room != null) {
UserRoomCompose(baseNote, room, accountViewModel, nav)
} else {
Box(
Modifier
.height(Size75dp)
.fillMaxWidth()
) {
// Makes sure just a max amount of objects are loaded.
}
}
}
@@ -239,14 +243,14 @@ private fun ChannelTitleWithBoostInfo(channelName: String, modifier: Modifier) {
@Composable
private fun UserRoomCompose(
note: Note,
user: User,
room: ChatroomKey,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
val route = remember(room) {
"Room/${room.hashCode()}"
}
val createAt by remember(note) {
@@ -269,13 +273,15 @@ private fun UserRoomCompose(
ChannelName(
channelPicture = {
NonClickableUserPicture(
baseUser = user,
NonClickableUserPictures(
users = room.users,
accountViewModel = accountViewModel,
size = Size55dp
)
},
channelTitle = { UsernameDisplay(user, it) },
channelTitle = {
RoomNameDisplay(room, it, accountViewModel.userProfile())
},
channelLastTime = createAt,
channelLastContent = content,
hasNewMessages = hasNewMessages,
@@ -283,6 +289,116 @@ private fun UserRoomCompose(
)
}
@Composable
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
val roomSubject by loggedInUser.live().messages.map {
it.user.privateChatrooms[room]?.subject
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
Crossfade(targetState = roomSubject, modifier) {
if (it != null && it.isNotBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it)
}
} else {
DisplayUserSetAsSubject(room)
}
}
}
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String
) {
Row() {
Text(
text = subject,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = " - ",
fontWeight = FontWeight.Bold,
maxLines = 1
)
LoadUser(baseUserHex = user) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f))
}
}
}
}
@Composable
fun DisplayUserSetAsSubject(
room: ChatroomKey,
fontWeight: FontWeight = FontWeight.Bold
) {
val userList = remember(room) {
room.users.toList()
}
if (userList.size == 1) {
// Regular Design
Row() {
LoadUser(baseUserHex = userList[0]) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight)
}
}
}
} else {
Row() {
userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value ->
LoadUser(baseUserHex = value) {
it?.let {
ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight)
}
}
if (!isLast) {
Text(
text = ", ",
fontWeight = fontWeight,
maxLines = 1
)
}
}
}
}
}
@Composable
fun DisplayRoomSubject(roomSubject: String, fontWeight: FontWeight = FontWeight.Bold) {
Row() {
Text(
text = roomSubject,
fontWeight = fontWeight,
maxLines = 1
)
}
}
@Composable
fun ShortUsernameDisplay(baseUser: User, weight: Modifier = Modifier, fontWeight: FontWeight = FontWeight.Bold) {
val userName by baseUser.live().metadata.map {
it.user.toBestShortFirstName()
}.distinctUntilChanged().observeAsState(baseUser.toBestShortFirstName())
Crossfade(targetState = userName, modifier = weight) {
CreateTextWithEmoji(
text = it,
tags = baseUser.info?.tags,
fontWeight = fontWeight,
maxLines = 1
)
}
}
@Composable
private fun WatchNotificationChanges(
note: Note,
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
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.ChatroomKeyable
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
@@ -1180,8 +1181,10 @@ fun routeFor(note: Note, loggedIn: User): String? {
note.channelHex()?.let {
return "Channel/$it"
}
} else if (noteEvent is PrivateDmEvent) {
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
} else if (noteEvent is ChatroomKeyable) {
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${note.idHex}"
} else {
@@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
@@ -429,7 +430,7 @@ private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onD
)
@Composable
private fun QuickActionAlertDialog(
fun QuickActionAlertDialog(
title: String,
textContent: String,
buttonIcon: ImageVector,
@@ -438,6 +439,62 @@ private fun QuickActionAlertDialog(
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
QuickActionAlertDialog(
title = title,
textContent = textContent,
icon = {
Icon(
imageVector = buttonIcon,
contentDescription = null
)
},
buttonText = buttonText,
buttonColors = buttonColors,
onClickDoOnce = onClickDoOnce,
onClickDontShowAgain = onClickDontShowAgain,
onDismiss = onDismiss
)
}
@Composable
fun QuickActionAlertDialog(
title: String,
textContent: String,
buttonIconResource: Int,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
QuickActionAlertDialog(
title = title,
textContent = textContent,
icon = {
Icon(
painter = painterResource(buttonIconResource),
contentDescription = null
)
},
buttonText = buttonText,
buttonColors = buttonColors,
onClickDoOnce = onClickDoOnce,
onClickDontShowAgain = onClickDontShowAgain,
onDismiss = onDismiss
)
}
@Composable
fun QuickActionAlertDialog(
title: String,
textContent: String,
icon: @Composable () -> Unit,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -461,10 +518,7 @@ private fun QuickActionAlertDialog(
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = buttonIcon,
contentDescription = null
)
icon()
Spacer(Modifier.width(8.dp))
Text(buttonText)
}
@@ -515,7 +515,13 @@ private fun BoostWithDialog(
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
NewPostView(
onClose = { wantsToQuote = null },
baseReplyTo = null,
quote = wantsToQuote,
accountViewModel = accountViewModel,
nav = nav
)
}
BoostReaction(baseNote, grayTint, accountViewModel) {
@@ -535,7 +541,13 @@ private fun ReplyReactionWithDialog(
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
NewPostView(
onClose = { wantsToReplyTo = null },
baseReplyTo = wantsToReplyTo,
quote = null,
accountViewModel = accountViewModel,
nav = nav
)
}
ReplyReaction(baseNote, grayTint, accountViewModel) {
@@ -41,12 +41,14 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -176,18 +178,79 @@ fun ClickableUserPicture(
}
@Composable
fun NonClickableUserPicture(
baseUser: User,
fun NonClickableUserPictures(
users: ImmutableSet<HexKey>,
size: Dp,
accountViewModel: AccountViewModel,
modifier: Modifier = remember { Modifier }
accountViewModel: AccountViewModel
) {
val myBoxModifier = remember {
Modifier.size(size)
}
Box(myBoxModifier, contentAlignment = Alignment.TopEnd) {
BaseUserPicture(baseUser, size, accountViewModel, modifier)
val userList = remember(users) {
users.toList()
}
when (userList.size) {
0 -> {}
1 -> LoadUser(baseUserHex = userList[0]) {
it?.let {
BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier)
}
}
2 -> {
LoadUser(baseUserHex = userList[0]) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart))
}
}
LoadUser(baseUserHex = userList[1]) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd))
}
}
}
3 -> {
LoadUser(baseUserHex = userList[0]) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter))
}
}
LoadUser(baseUserHex = userList[2]) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
}
else -> {
LoadUser(baseUserHex = userList[0]) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart))
}
}
LoadUser(baseUserHex = userList[2]) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
LoadUser(baseUserHex = userList[3]) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd))
}
}
}
}
}
}
@@ -196,14 +259,11 @@ fun BaseUserPicture(
baseUser: User,
size: Dp,
accountViewModel: AccountViewModel,
modifier: Modifier = remember { Modifier }
innerModifier: Modifier = remember { Modifier },
outerModifier: Modifier = remember { Modifier.size(size) }
) {
val myBoxModifier = remember {
Modifier.size(size)
}
Box(myBoxModifier, contentAlignment = Alignment.TopEnd) {
InnerBaseUserPicture(baseUser, size, accountViewModel, modifier)
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
InnerBaseUserPicture(baseUser, size, accountViewModel, innerModifier)
}
}
@@ -44,7 +44,7 @@ fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayBut
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold) {
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
@@ -57,9 +57,9 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton)
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight)
} else {
NPubDisplay(npubDisplay, weight)
NPubDisplay(npubDisplay, weight, fontWeight)
}
}
}
@@ -71,27 +71,28 @@ private fun UserNameDisplay(
npubDisplay: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton, fontWeight)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
UserDisplay(bestDisplayName, tags, modifier, showPlayButton, fontWeight)
} else if (bestUserName != null) {
UserDisplay(bestUserName, tags, modifier, showPlayButton)
UserDisplay(bestUserName, tags, modifier, showPlayButton, fontWeight)
} else {
NPubDisplay(npubDisplay, modifier)
NPubDisplay(npubDisplay, modifier, fontWeight)
}
}
@Composable
fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold) {
Text(
text = npubDisplay,
fontWeight = FontWeight.Bold,
fontWeight = fontWeight,
modifier = modifier,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
overflow = TextOverflow.Ellipsis
)
}
@@ -100,13 +101,14 @@ private fun UserDisplay(
bestDisplayName: String,
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
fontWeight = fontWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
@@ -124,13 +126,14 @@ private fun UserAndUsernameDisplay(
tags: ImmutableListOfLists<String>?,
bestUserName: String,
modifier: Modifier,
showPlayButton: Boolean = true
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
fontWeight = fontWeight,
maxLines = 1
)
CreateTextWithEmoji(
@@ -3,20 +3,28 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
@Composable
fun RefreshingChatroomFeedView(
@@ -100,6 +108,36 @@ fun ChatroomFeedLoaded(
nav = nav,
onWantsToReply = onWantsToReply
)
NewSubject(item)
}
}
}
@Composable
fun NewSubject(note: Note) {
val subject = remember(note) {
note.event?.subject()
}
if (subject != null) {
NewSubject(newSubject = subject)
}
}
@Composable
fun NewSubject(newSubject: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Divider(
modifier = Modifier.weight(1f)
)
Text(
text = newSubject,
fontWeight = FontWeight.Bold,
fontSize = Font14SP,
modifier = HalfPadding
)
Divider(
modifier = Modifier.weight(1f)
)
}
}
@@ -16,9 +16,13 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomHeaderCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -84,16 +88,36 @@ private fun FeedLoaded(
LaunchedEffect(key1 = markAsRead.value) {
if (markAsRead.value) {
for (note in state.feed.value) {
note.event?.let {
var myEvent = if (note.event is GiftWrapEvent) {
val unwrapped = accountViewModel.unwrap(note.event as GiftWrapEvent)
if (unwrapped is SealedGossipEvent) {
accountViewModel.unseal(unwrapped)
} else {
unwrapped
}
} else {
note.event
}
myEvent?.let { noteEvent ->
val channelHex = note.channelHex()
val route = if (channelHex != null) {
"Channel/$channelHex"
} else if (note.event is ChatroomKeyable) {
val withKey = (note.event as ChatroomKeyable).chatroomKey(accountViewModel.userProfile().pubkeyHex)
withContext(Dispatchers.IO) {
accountViewModel.userProfile().createChatroom(withKey)
}
"Room/${withKey.hashCode()}"
} else {
val roomUser = (note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.account.userProfile().pubkeyHex)
"Room/$roomUser"
null
}
accountViewModel.account.markAsRead(route, it.createdAt())
route?.let {
accountViewModel.account.markAsRead(route, noteEvent.createdAt())
}
}
}
markAsRead.value = false
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -73,7 +74,7 @@ fun RefresheableView(
}
Box(modifier) {
Column {
Column(Modifier.fillMaxSize()) {
content()
}
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -55,8 +56,8 @@ class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : Fe
}
}
}
class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) {
class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatRoomFeedViewModel : ViewModel> create(modelClass: Class<NostrChatRoomFeedViewModel>): NostrChatRoomFeedViewModel {
return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel
}
@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.relays.RelayPool
@Stable
class RelayPoolViewModel : ViewModel() {
val connectionStatus = RelayPool.live.map {
val connectedRelays = it.relays.connectedRelays()
@@ -20,9 +20,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
@@ -280,6 +282,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.setHideDeleteRequestDialog()
}
val hideNIP24WarningDialog: Boolean
get() = account.hideNIP24WarningDialog
fun dontShowNIP24WarningDialog() {
account.setHideNIP24WarningDialog()
}
val hideBlockAlertDialog: Boolean
get() = account.hideBlockAlertDialog
@@ -326,6 +335,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun unwrap(event: GiftWrapEvent): Event? {
return account.unwrap(event)
}
fun unseal(event: SealedGossipEvent): Event? {
return account.unseal(event)
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel
@@ -135,7 +135,11 @@ fun ChatroomListScreen(
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(
pageCount = 2,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
ChatroomListFeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -10,52 +11,84 @@ 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.text.KeyboardOptions
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
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.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject
import com.vitorpamplona.amethyst.ui.note.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun ChatroomScreen(
userId: String?,
roomId: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (userId == null) return
if (roomId == null) return
LoadUser(userId) {
LoadRoom(roomId, accountViewModel) {
it?.let {
PrepareChatroomViewModels(
baseUser = it,
room = it,
accountViewModel = accountViewModel,
nav = nav
)
@@ -64,20 +97,72 @@ fun ChatroomScreen(
}
@Composable
fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun ChatroomScreenByAuthor(
authorPubKeyHex: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (authorPubKeyHex == null) return
LoadRoomByAuthor(authorPubKeyHex, accountViewModel) {
it?.let {
PrepareChatroomViewModels(
room = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@Composable
fun LoadRoom(roomId: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) {
var room by remember(roomId) {
mutableStateOf<ChatroomKey?>(null)
}
if (room == null) {
LaunchedEffect(key1 = roomId) {
launch(Dispatchers.IO) {
val newRoom = accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { it.hashCode().toString() == roomId }
if (room != newRoom) {
room = newRoom
}
}
}
}
content(room)
}
@Composable
fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) {
val room by remember(authorPubKeyHex) {
mutableStateOf<ChatroomKey?>(ChatroomKey(persistentSetOf(authorPubKeyHex)))
}
content(room)
}
@Composable
fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "ChatroomViewModels",
key = room.hashCode().toString() + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory(
baseUser,
room,
accountViewModel.account
)
)
val newPostModel: NewPostViewModel = viewModel()
newPostModel.account = accountViewModel.account
newPostModel.requiresNIP24 = room.users.size > 1
if (newPostModel.requiresNIP24) {
newPostModel.nip24 = true
}
ChatroomScreen(
baseUser = baseUser,
room = room,
feedViewModel = feedViewModel,
newPostModel = newPostModel,
accountViewModel = accountViewModel,
@@ -87,7 +172,7 @@ fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel
@Composable
fun ChatroomScreen(
baseUser: User,
room: ChatroomKey,
feedViewModel: NostrChatroomFeedViewModel,
newPostModel: NewPostViewModel,
accountViewModel: AccountViewModel,
@@ -95,11 +180,11 @@ fun ChatroomScreen(
) {
val context = LocalContext.current
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, baseUser)
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(baseUser, accountViewModel) {
LaunchedEffect(room, accountViewModel) {
launch(Dispatchers.IO) {
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
@@ -112,7 +197,7 @@ fun ChatroomScreen(
}
}
DisposableEffect(baseUser, accountViewModel) {
DisposableEffect(room, accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
@@ -143,7 +228,7 @@ fun ChatroomScreen(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${baseUser.pubkeyHex}",
routeForLastRead = "Room/${room.hashCode()}",
onWantsToReply = {
replyTo.value = it
}
@@ -161,15 +246,26 @@ fun ChatroomScreen(
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = baseUser,
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false
)
if (newPostModel.nip24 || room.users.size > 1) {
accountViewModel.account.sendNIP24PrivateMessage(
message = newPostModel.message.text,
toUsers = room.users.toList(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false
)
} else {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = room.users.first(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false
)
}
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop()
@@ -178,6 +274,177 @@ fun ChatroomScreen(
}
}
@Composable
fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit
) {
Row(
modifier = EditFieldModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val context = LocalContext.current
MyTextField(
value = channelScreenModel.message,
onValueChange = {
channelScreenModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
shape = EditFieldBorder,
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = stringResource(R.string.reply_here),
color = MaterialTheme.colors.placeholderText
)
},
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
trailingIcon = {
PostButton(
onPost = {
onSendNewMessage()
},
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = EditFieldTrailingIconModifier
)
},
leadingIcon = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 6.dp)) {
UploadFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colors.placeholderText,
modifier = Modifier
.size(30.dp)
.padding(start = 2.dp)
) {
val fileServer = if (isPrivate) {
// TODO: Make private servers
when (accountViewModel.account.defaultFileServer) {
ServersAvailable.NOSTR_BUILD -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NOSTR_BUILD_NIP_94 -> ServersAvailable.NOSTR_BUILD
ServersAvailable.NOSTRIMG_NIP_94 -> ServersAvailable.NOSTRIMG
ServersAvailable.NOSTRFILES_DEV_NIP_94 -> ServersAvailable.NOSTRFILES_DEV
ServersAvailable.NOSTRCHECK_ME_NIP_94 -> ServersAvailable.NOSTRCHECK_ME
ServersAvailable.NIP95 -> ServersAvailable.NOSTR_BUILD
}
} else {
accountViewModel.account.defaultFileServer
}
channelScreenModel.upload(it, "", false, fileServer, context)
}
var wantsToActivateNIP24 by remember {
mutableStateOf(false)
}
if (wantsToActivateNIP24) {
NewFeatureNIP24AlertDialog(
accountViewModel = accountViewModel,
onConfirm = {
channelScreenModel.toggleNIP04And24()
},
onDismiss = {
wantsToActivateNIP24 = false
}
)
}
IconButton(
modifier = Size30Modifier,
onClick = {
if (!accountViewModel.hideNIP24WarningDialog && !channelScreenModel.nip24 && !channelScreenModel.requiresNIP24) {
wantsToActivateNIP24 = true
} else {
channelScreenModel.toggleNIP04And24()
}
}
) {
if (channelScreenModel.nip24) {
Icon(
painter = painterResource(id = R.drawable.incognito),
null,
modifier = Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = Color.Green
)
} else {
Icon(
painter = painterResource(id = R.drawable.incognito_off),
null,
modifier = Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = MaterialTheme.colors.placeholderText
)
}
}
}
},
colors = TextFieldDefaults.textFieldColors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
}
@Composable
fun NewFeatureNIP24AlertDialog(accountViewModel: AccountViewModel, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringResource(R.string.new_feature_nip24_might_not_be_available_title),
textContent = stringResource(R.string.new_feature_nip24_might_not_be_available_description),
buttonIconResource = R.drawable.incognito,
buttonText = stringResource(R.string.new_feature_nip24_activate),
onClickDoOnce = {
scope.launch(Dispatchers.IO) {
onConfirm()
}
onDismiss()
},
onClickDontShowAgain = {
scope.launch(Dispatchers.IO) {
onConfirm()
accountViewModel.dontShowNIP24WarningDialog()
}
onDismiss()
},
onDismiss = onDismiss
)
}
@Composable
fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first()) { baseUser ->
if (baseUser != null) {
ChatroomHeader(baseUser = baseUser, modifier = modifier, accountViewModel = accountViewModel, nav = nav)
}
}
} else {
GroupChatroomHeader(room = room, modifier = modifier, accountViewModel = accountViewModel, nav = nav)
}
}
@Composable
fun ChatroomHeader(
baseUser: User,
@@ -186,9 +453,11 @@ fun ChatroomHeader(
nav: (String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth().clickable(
onClick = { nav("User/${baseUser.pubkeyHex}") }
)
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { nav("User/${baseUser.pubkeyHex}") }
)
) {
Column(
verticalArrangement = Arrangement.Center,
@@ -213,3 +482,50 @@ fun ChatroomHeader(
)
}
}
@Composable
fun GroupChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
users = room.users,
accountViewModel = accountViewModel,
size = Size34dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
RoomNameOnlyDisplay(room, Modifier, accountViewModel.userProfile())
DisplayUserSetAsSubject(room, FontWeight.Normal)
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun RoomNameOnlyDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
val roomSubject by loggedInUser.live().messages.map {
it.user.privateChatrooms[room]?.subject
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
Crossfade(targetState = roomSubject, modifier) {
if (it != null && it.isNotBlank()) {
DisplayRoomSubject(it)
}
}
}
@@ -23,13 +23,14 @@ import com.vitorpamplona.amethyst.R
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.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun LoadRedirectScreen(eventId: String?, navController: NavController) {
fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, navController: NavController) {
if (eventId == null) return
var noteBase by remember { mutableStateOf<Note?>(null) }
@@ -60,13 +61,14 @@ fun LoadRedirectScreen(eventId: String?, navController: NavController) {
noteBase?.let {
LoadRedirectScreen(
baseNote = it,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun LoadRedirectScreen(baseNote: Note, nav: (String) -> Unit) {
fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val scope = rememberCoroutineScope()
@@ -81,8 +83,17 @@ fun LoadRedirectScreen(baseNote: Note, nav: (String) -> Unit) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
} else if (event is PrivateDmEvent) {
nav("Room/${note.author?.pubkeyHex}")
} else if (event is ChatroomKeyable) {
note.author?.let {
val withKey = (note.event as ChatroomKeyable)
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
withContext(Dispatchers.IO) {
accountViewModel.userProfile().createChatroom(withKey)
}
nav("Room/${withKey.hashCode()}")
}
} else if (channelHex != null) {
nav("Channel/$channelHex")
} else {
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ChatroomKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -103,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -1540,11 +1542,19 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> U
@Composable
private fun MessageButton(user: User, nav: (String) -> Unit) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { nav("Room/${user.pubkeyHex}") },
onClick = {
scope.launch(Dispatchers.IO) {
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
user.createChatroom(withKey)
nav("Room/${withKey.hashCode()}")
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
@@ -426,11 +426,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
NewPostView(onClose = { wantsToReplyTo = null }, baseReplyTo = wantsToReplyTo, quote = null, accountViewModel = accountViewModel, nav = nav)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
NewPostView(onClose = { wantsToQuote = null }, baseReplyTo = null, quote = wantsToQuote, accountViewModel = accountViewModel, nav = nav)
}
Spacer(modifier = Modifier.height(8.dp))