diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ChatroomKeyTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ChatroomKeyTest.kt new file mode 100644 index 0000000000..a2a36bbc96 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ChatroomKeyTest.kt @@ -0,0 +1,20 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.model.ChatroomKey +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ChatroomKeyTest { + @Test + fun testEquals() { + val k1 = ChatroomKey(persistentSetOf("Key1", "Key2")) + val k2 = ChatroomKey(persistentSetOf("Key1", "Key2")) + + assertEquals(k1, k2) + assertEquals(k1.hashCode(), k2.hashCode()) + } +} diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/CryptoUtilsTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/CryptoUtilsTest.kt index d3c845a3d7..1a10c08be7 100644 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/CryptoUtilsTest.kt +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/CryptoUtilsTest.kt @@ -59,6 +59,19 @@ class CryptoUtilsTest { assertEquals(msg, decrypted) } + @Test + fun encryptDecryptNIP4WithJsonSchemaTest() { + val msg = "Hi" + + val privateKey = CryptoUtils.privkeyCreate() + val publicKey = CryptoUtils.pubkeyCreate(privateKey) + + val encrypted = CryptoUtils.encryptNIP04Json(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) + + assertEquals(msg, decrypted) + } + @Test fun encryptDecryptNIP24Test() { val msg = "Hi" @@ -78,10 +91,9 @@ class CryptoUtilsTest { val privateKey = CryptoUtils.privkeyCreate() val publicKey = CryptoUtils.pubkeyCreate(privateKey) - val sharedSecret = CryptoUtils.getSharedSecretNIP04(privateKey, publicKey) - val encrypted = CryptoUtils.encryptNIP04(msg, sharedSecret) - val decrypted = CryptoUtils.decryptNIP04(encrypted, sharedSecret) + val encrypted = CryptoUtils.encryptNIP04(msg, privateKey, publicKey) + val decrypted = CryptoUtils.decryptNIP04(encrypted, privateKey, publicKey) assertEquals(msg, decrypted) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 6190533103..717b8243c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4c9e78d2fa..11a0a75257 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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?, 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?, 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, + subject: String? = null, + replyingTo: Note? = null, + mentions: List?, + 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) { + 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): 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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f573073298..59e413f8d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 2fc127abee..3d9705b1c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -104,11 +104,13 @@ open class Note(val idHex: String) { open fun createdAt() = event?.createdAt() fun loadEvent(event: Event, author: User, replyTo: List) { - 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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt index d2496ce420..73d3942d29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelayInformation.kt @@ -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?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/TimeUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/TimeUtils.kt index 18a7636478..8098bdfcca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/TimeUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/TimeUtils.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 87a627df16..69e300cbb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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 +) + @Stable class User(val pubkeyHex: String) { var info: UserMetadata? = null @@ -43,7 +50,7 @@ class User(val pubkeyHex: String) { var relaysBeingUsed = mapOf() private set - var privateChatrooms = mapOf() + var privateChatrooms = mapOf() 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 = 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): Boolean { + return roomMessages.any { it.author?.pubkeyHex in keySet } + } + fun pruneMessagesToTheLatestOnly(): Set { val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CryptoUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CryptoUtils.kt index f879c11e01..b2aac5c36a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CryptoUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CryptoUtils.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index 94879c6d10..7cc1199e18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -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 } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index 309e630449..1623901659 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -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(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 1803d14237..6d6427e584 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/SodiumUtils.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/SodiumUtils.kt index 5cf64e8aab..0c16f058b8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/SodiumUtils.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/SodiumUtils.kt @@ -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 } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt index 6ccf125d0c..aca414b890 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/AudioTrackEvent.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 6913cc980a..0c11ca5252 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -20,7 +20,10 @@ open class BaseTextNoteEvent( fun mentions() = taggedUsers() open fun replyTos() = taggedEvents() + @Transient private var citedUsersCache: Set? = null + + @Transient private var citedNotesCache: Set? = null fun citedUsers(): Set { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChatMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChatMessageEvent.kt index 236e23b788..6b8788d342 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChatMessageEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChatMessageEvent.kt @@ -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>, 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 { + 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? = null, + subject: String? = null, replyTos: List? = null, mentions: List? = 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 +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt index dc6b66c8a5..800618b9d5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt @@ -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 by lazy { tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { try { @@ -35,18 +37,22 @@ class ContactListEvent( }.toSet() } + @delegate:Transient val verifiedFollowTagSet: Set by lazy { unverifiedFollowTagSet().map { it.lowercase() }.toSet() } + @delegate:Transient val verifiedFollowGeohashSet: Set by lazy { unverifiedFollowGeohashSet().map { it.lowercase() }.toSet() } + @delegate:Transient val verifiedFollowCommunitySet: Set by lazy { unverifiedFollowAddressSet().toSet() } + @delegate:Transient val verifiedFollowKeySetAndMe: Set by lazy { verifiedFollowKeySet + pubKey } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index afa9705b2a..a8a20ae8db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -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 { + 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 { override fun serialize( src: Event, @@ -254,6 +276,38 @@ open class Event( } } + private class GossipSerializer : JsonSerializer { + 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 { 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()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventFactory.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventFactory.kt index 5ea09edd7e..fcb5301c68 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventFactory.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventFactory.kt @@ -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>, - 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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt index f548f60b4a..a4c178abd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt @@ -56,6 +56,7 @@ interface EventInterface { fun zapAddress(): String? fun isSensitive(): Boolean + fun subject(): String? fun zapraiserAmount(): Long? fun taggedAddresses(): List diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GiftWrapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GiftWrapEvent.kt index e33cec26a3..e48828b046 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GiftWrapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GiftWrapEvent.kt @@ -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 = 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( - 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()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/NIP24Factory.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/NIP24Factory.kt index 32ffa0f25c..d91627e101 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/NIP24Factory.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/NIP24Factory.kt @@ -8,14 +8,28 @@ class NIP24Factory { fun createMsgNIP24( msg: String, to: List, - from: ByteArray + from: ByteArray, + subject: String? = null, + replyTos: List? = null, + mentions: List? = null, + zapReceiver: String? = null, + markAsSensitive: Boolean = false, + zapRaiserAmount: Long? = null, + geohash: String? = null ): List { 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, from: ByteArray): List { + 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, from: ByteArray): List { + 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 + ) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt index d89ad01d11..3ca959e2c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt @@ -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>, 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. * diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/SealedGossipEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/SealedGossipEvent.kt index 0e51426c7a..556404addc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/SealedGossipEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/SealedGossipEvent.kt @@ -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 = 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( - 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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index 5037fa0b11..3eb72ca05d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 4d7454710e..7b836badc5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -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) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index bf51126e9c..aa184f8af0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 265f71c49a..352ed7e52d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 9e49b28249..19d3b977b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 85332583f1..bd9b001bd6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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?>(null) @@ -48,7 +56,12 @@ open class NewPostViewModel() : ViewModel() { var userSuggestions by mutableStateOf>(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(null) @@ -91,6 +104,9 @@ open class NewPostViewModel() : ViewModel() { var wantsZapraiser by mutableStateOf(false) var zapRaiserAmount by mutableStateOf(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() 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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt index 0c9ff2cf92..7bedd43c89 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/ChannelFabColumn.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c8b47ae0a9..1c911a3edb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 0235ca3aef..8acc685703 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -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() { +class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : AdditiveFeedFilter() { // returns the last Note of each user. override fun feedKey(): String { - return withUser.pubkeyHex + return withUser.hashCode().toString() } override fun feed(): List { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 6d2c341f7f..12359ec92b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -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 @@ -75,7 +79,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter 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, account: Account): MutableMap { + private fun filterRelevantPrivateMessages(newItems: Set, account: Account): MutableMap { val me = account.userProfile() val followingKeySet = account.followingKeySet() - val newRelevantPrivateMessages = mutableMapOf() - 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() + 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) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 09ce59398f..90fdac2cfb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -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 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 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 } } - private fun filterRelevantPrivateMessages(newItems: Set, account: Account): MutableMap { + private fun filterRelevantPrivateMessages(newItems: Set, account: Account): MutableMap { val me = account.userProfile() val followingKeySet = account.followingKeySet() - val newRelevantPrivateMessages = mutableMapOf() + val newRelevantPrivateMessages = mutableMapOf() 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) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index e076423277..af84814490 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -39,6 +39,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter() 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 && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4bbf9a4f69..6775b95628 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 ) }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b7c82f3fa4..efdfbc5114 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 8ba641d20a..7400306ae8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt index 366339f645..3896ea550b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomHeaderCompose.kt @@ -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(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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 639ea199da..93b3290f73 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 4ad25d76ef..e8fa4a81ee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index dddb9195d5..0ef17030c7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 306baf2bb1..e4d7a79fd0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -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, 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) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index e5e3670a9f..cb17d00bbf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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?, 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?, 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?, 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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index b63b748407..2c8f66d923 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 42f95a6dd5..80c425daab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index d638a08f8e..d3a3bda175 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -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() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index c35d629c66..b6c70485d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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 create(modelClass: Class): NostrChatRoomFeedViewModel { return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt index b33637b3f4..e9f91be1ca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayPoolViewModel.kt @@ -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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0cbc665b1e..dd7c9a7205 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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 create(modelClass: Class): AccountViewModel { return AccountViewModel(account) as AccountViewModel diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt index ccaf471dac..81c3832291 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 11abee43fe..42a2729fbf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -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(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(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) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index 7928a1f15d..112d8c6404 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -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(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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index bf34dba7f8..b92980d44b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index fbf983ce97..fd17b304a4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -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)) diff --git a/app/src/main/res/drawable/giftwrap_off.xml b/app/src/main/res/drawable/giftwrap_off.xml new file mode 100644 index 0000000000..f3348f8caa --- /dev/null +++ b/app/src/main/res/drawable/giftwrap_off.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/app/src/main/res/drawable/giftwrap_on.xml b/app/src/main/res/drawable/giftwrap_on.xml new file mode 100644 index 0000000000..6ee6d4274c --- /dev/null +++ b/app/src/main/res/drawable/giftwrap_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/incognito.xml b/app/src/main/res/drawable/incognito.xml new file mode 100644 index 0000000000..6b3e681807 --- /dev/null +++ b/app/src/main/res/drawable/incognito.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/incognito_off.xml b/app/src/main/res/drawable/incognito_off.xml new file mode 100644 index 0000000000..4f9e79d6ec --- /dev/null +++ b/app/src/main/res/drawable/incognito_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5ae18cbec..d1b140e2f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -521,4 +521,15 @@ Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing + + New Feature + Activating this mode requires Amethyst to send a NIP-24 message (GiftWrapped, Sealed Direct and Group Messages). NIP-24 is new and most clients have not implemented it yet. Make sure the receiver is using a compatible client. + Activate + + Public + Private + To + Subject + Topic of the conversation + "@User1, @User2, @User3" diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index a933abb527..c36dca8ef4 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -12,7 +12,7 @@ class PushNotificationReceiverService : FirebaseMessagingService() { remoteMessage.data.let { val eventStr = remoteMessage.data["event"] ?: return val event = Event.fromJson(eventStr, true) - EventNotificationConsumer(applicationContext).consume(event) + EventNotificationConsumer(applicationContext).unwrapAndConsume(event) } }