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 000000000..a2a36bbc9 --- /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 d3c845a3d..1a10c08be 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 619053310..717b8243c 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 4c9e78d2f..11a0a7525 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 f57307329..59e413f8d 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 2fc127abe..3d9705b1c 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 d2496ce42..73d3942d2 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 18a763647..8098bdfcc 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 87a627df1..69e300cbb 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 f879c11e0..b2aac5c36 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 94879c6d1..7cc1199e1 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 309e63044..162390165 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 1803d1423..6d6427e58 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 5cf64e8aa..0c16f058b 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 6ccf125d0..aca414b89 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 6913cc980..0c11ca525 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 236e23b78..6b8788d34 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 dc6b66c8a..800618b9d 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 afa9705b2..a8a20ae8d 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 5ea09edd7..fcb5301c6 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 f548f60b4..a4c178abd 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 e33cec26a..e48828b04 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 32ffa0f25..d91627e10 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 d89ad01d1..3ca959e2c 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 0e51426c7..556404add 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 5037fa0b1..3eb72ca05 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 4d7454710..7b836badc 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 bf51126e9..aa184f8af 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 265f71c49..352ed7e52 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 9e49b2824..19d3b977b 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 85332583f..bd9b001bd 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 0c9ff2cf9..7bedd43c8 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 c8b47ae0a..1c911a3ed 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 0235ca3ae..8acc68570 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 6d2c341f7..12359ec92 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 09ce59398..90fdac2cf 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 e07642327..af8481449 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 4bbf9a4f6..6775b9562 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 b7c82f3fa..efdfbc511 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 8ba641d20..7400306ae 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 366339f64..3896ea550 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 639ea199d..93b3290f7 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 4ad25d76e..e8fa4a81e 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 dddb9195d..0ef17030c 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 306baf2bb..e4d7a79fd 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 e5e3670a9..cb17d00bb 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 b63b74840..2c8f66d92 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 42f95a6dd..80c425daa 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 d638a08f8..d3a3bda17 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 c35d629c6..b6c70485d 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 b33637b3f..e9f91be1c 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 0cbc665b1..dd7c9a720 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 ccaf471da..81c383229 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 11abee43f..42a2729fb 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 7928a1f15..112d8c640 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 bf34dba7f..b92980d44 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 fbf983ce9..fd17b304a 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 000000000..f3348f8ca --- /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 000000000..6ee6d4274 --- /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 000000000..6b3e68180 --- /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 000000000..4f9e79d6e --- /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 f5ae18cbe..d1b140e2f 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 a933abb52..c36dca8ef 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) } }