mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 06:44:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eec87f017a | ||
|
|
799037502f | ||
|
|
f2b727c587 | ||
|
|
b255d1827e | ||
|
|
d9e01da7e2 | ||
|
|
99a98439cd | ||
|
|
724e7e2378 | ||
|
|
0619c9ffac | ||
|
|
cb7b51e8d1 | ||
|
|
c8172265dd | ||
|
|
cce9d424bc | ||
|
|
45227b75dc | ||
|
|
6eaba7956c | ||
|
|
cc4b94f738 | ||
|
|
423628a104 | ||
|
|
d262b48f31 | ||
|
|
f97a0468cc | ||
|
|
b4d87686ac | ||
|
|
8cbf07c917 | ||
|
|
30fc4c35f4 | ||
|
|
7912d492e5 | ||
|
|
d921eb6138 | ||
|
|
6875fdc4d0 | ||
|
|
0bc701f5c9 | ||
|
|
5de0808b1e | ||
|
|
9d680c9f82 | ||
|
|
63fc4c570d | ||
|
|
ab2fff0194 | ||
|
|
c644ba9a3e | ||
|
|
2791873048 | ||
|
|
89266bc76f | ||
|
|
9573b4abec | ||
|
|
ddcff8fa15 |
@@ -76,7 +76,7 @@ jobs:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ github.ref }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: true
|
||||
|
||||
# Google Play APK
|
||||
- name: Upload Play APK Universal Asset
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 269
|
||||
versionName "0.72.2"
|
||||
versionCode 271
|
||||
versionName "0.73.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
Vendored
+22
@@ -30,6 +30,28 @@
|
||||
# preserve access to native classses
|
||||
-keep class fr.acinq.secp256k1.** { *; }
|
||||
|
||||
# JNA For Libsodium
|
||||
-keep class com.goterl.lazysodium.** { *; }
|
||||
|
||||
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
|
||||
-keep class com.sun.jna.ToNativeConverter { *; }
|
||||
-keep class com.sun.jna.NativeMapped { *; }
|
||||
-keep class com.sun.jna.CallbackReference { *; }
|
||||
-keep class com.sun.jna.ptr.IntByReference { *; }
|
||||
-keep class com.sun.jna.NativeLong { *; }
|
||||
-keep class com.sun.jna.Structure { *; }
|
||||
-keep class com.sun.jna.Structure$* { *; }
|
||||
-keep class com.sun.jna.Native$ffi_callback { *; }
|
||||
-keep class * implements com.sun.jna.Structure$* { *; }
|
||||
-keep class * implements com.sun.jna.Native$* { *; }
|
||||
-keep class com.sun.jna.Native {
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
|
||||
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
|
||||
private static java.lang.Class nativeType(java.lang.Class);
|
||||
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
|
||||
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
|
||||
}
|
||||
|
||||
# GSON parsing
|
||||
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ private object PrefKeys {
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
@@ -231,6 +232,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
@@ -365,6 +367,7 @@ object LocalPreferences {
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
@@ -431,6 +434,7 @@ object LocalPreferences {
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP24WarningDialog = hideNIP24WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
|
||||
@@ -72,6 +72,7 @@ class Account(
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP24WarningDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy?,
|
||||
var proxyPort: Int,
|
||||
@@ -219,23 +220,57 @@ class Account(
|
||||
return
|
||||
}
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(emojiUrl, it, keyPair.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
if (note.event is ChatMessageEvent) {
|
||||
val event = note.event as ChatMessageEvent
|
||||
val users = event.recipientsPubKey().plus(event.pubKey).toSet().toList()
|
||||
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
val giftWraps = NIP24Factory().createReactionWithinGroup(
|
||||
emojiUrl = emojiUrl,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
from = keyPair.privKey!!
|
||||
)
|
||||
|
||||
broadcastPrivately(giftWraps)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(reaction, it, keyPair.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
note.event?.let {
|
||||
val giftWraps = NIP24Factory().createReactionWithinGroup(
|
||||
content = reaction,
|
||||
originalNote = it,
|
||||
to = users,
|
||||
from = keyPair.privKey!!
|
||||
)
|
||||
|
||||
broadcastPrivately(giftWraps)
|
||||
}
|
||||
} else {
|
||||
if (reaction.startsWith(":")) {
|
||||
val emojiUrl = EmojiUrl.decode(reaction)
|
||||
if (emojiUrl != null) {
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(emojiUrl, it, keyPair.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(reaction, it, keyPair.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,14 +869,18 @@ class Account(
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
sendPrivateMessage(message, toUser.pubkeyHex, replyingTo, mentions, zapReceiver, wantsToMarkAsSensitive, zapRaiserAmount, geohash)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: HexKey, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, geohash: String? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = toUser.pubkey(),
|
||||
publishedRecipientPubKey = toUser.pubkey(),
|
||||
recipientPubKey = toUser.hexToByteArray(),
|
||||
publishedRecipientPubKey = toUser.hexToByteArray(),
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
@@ -856,6 +895,59 @@ class Account(
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendNIP24PrivateMessage(
|
||||
message: String,
|
||||
toUsers: List<HexKey>,
|
||||
subject: String? = null,
|
||||
replyingTo: Note? = null,
|
||||
mentions: List<User>?,
|
||||
zapReceiver: String? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvents = NIP24Factory().createMsgNIP24(
|
||||
msg = message,
|
||||
to = toUsers,
|
||||
subject = subject,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
from = keyPair.privKey!!
|
||||
)
|
||||
|
||||
broadcastPrivately(signedEvents)
|
||||
}
|
||||
|
||||
fun broadcastPrivately(signedEvents: List<GiftWrapEvent>) {
|
||||
signedEvents.forEach {
|
||||
Client.send(it)
|
||||
|
||||
// Only keep in cache the GiftWrap for the account.
|
||||
if (it.recipientPubKey() == keyPair.pubKey.toHexKey()) {
|
||||
it.cachedGift(keyPair.privKey!!)?.let {
|
||||
if (it is SealedGossipEvent) {
|
||||
it.cachedGossip(keyPair.privKey!!)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendCreateNewChannel(name: String, about: String, picture: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -1343,11 +1435,22 @@ class Account(
|
||||
follow(channel)
|
||||
}
|
||||
|
||||
fun unwrap(event: GiftWrapEvent): Event? {
|
||||
if (!isWriteable()) return null
|
||||
return event.cachedGift(keyPair.privKey!!)
|
||||
}
|
||||
|
||||
fun unseal(event: SealedGossipEvent): Event? {
|
||||
if (!isWriteable()) return null
|
||||
return event.cachedGossip(keyPair.privKey!!)
|
||||
}
|
||||
|
||||
fun decryptContent(note: Note): String? {
|
||||
val privKey = keyPair.privKey
|
||||
val event = note.event
|
||||
return if (event is PrivateDmEvent && keyPair.privKey != null) {
|
||||
event.plainContent(keyPair.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
|
||||
} else if (event is LnZapRequestEvent && keyPair.privKey != null) {
|
||||
return if (event is PrivateDmEvent && privKey != null) {
|
||||
event.plainContent(privKey, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
|
||||
} else if (event is LnZapRequestEvent && privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
event?.content()
|
||||
@@ -1478,6 +1581,12 @@ class Account(
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
fun convertGlobalRelays(): Array<String> {
|
||||
return localRelays.filter { it.feedTypes.contains(FeedType.GLOBAL) }
|
||||
.map { it.url }
|
||||
.toTypedArray()
|
||||
}
|
||||
|
||||
fun reconnectIfRelaysHaveChanged() {
|
||||
val newRelaySet = activeRelays() ?: convertLocalRelays()
|
||||
if (!Client.isSameRelaySetConfig(newRelaySet)) {
|
||||
@@ -1487,7 +1596,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun isAllHidden(users: Set<HexKey>): Boolean {
|
||||
return users.all { isHidden(it) }
|
||||
}
|
||||
|
||||
fun isHidden(user: User) = isHidden(user.pubkeyHex)
|
||||
|
||||
fun isHidden(userHex: String): Boolean {
|
||||
val blockList = getBlockList()
|
||||
|
||||
@@ -1566,6 +1680,11 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun setHideNIP24WarningDialog() {
|
||||
hideNIP24WarningDialog = true
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
hideBlockAlertDialog = true
|
||||
saveable.invalidateData()
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -1122,7 +1124,29 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet()
|
||||
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
if (recipients.isNotEmpty()) {
|
||||
recipients.forEach {
|
||||
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
|
||||
|
||||
val authorGroup = if (groupMinusRecipient.isEmpty()) {
|
||||
// note to self
|
||||
ChatroomKey(persistentSetOf(it.pubkeyHex))
|
||||
} else {
|
||||
ChatroomKey(groupMinusRecipient.toImmutableSet())
|
||||
}
|
||||
|
||||
it.addMessage(authorGroup, note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -1144,7 +1168,7 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: GiftWrapEvent, relay: Relay?) {
|
||||
fun consume(event: GiftWrapEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
@@ -1236,14 +1260,20 @@ object LocalCache {
|
||||
}
|
||||
|
||||
return notes.values.filter {
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
it.idHex.startsWith(text, true) ||
|
||||
it.idNote().startsWith(text, true)
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent) &&
|
||||
(
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
it.idHex.startsWith(text, true) ||
|
||||
it.idNote().startsWith(text, true)
|
||||
)
|
||||
} + addressables.values.filter {
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
it.idHex.startsWith(text, true)
|
||||
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent) &&
|
||||
(
|
||||
it.event?.content()?.contains(text, true) ?: false ||
|
||||
it.event?.matchTag1With(text) ?: false ||
|
||||
it.idHex.startsWith(text, true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1442,16 +1472,28 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun verifyAndConsume(event: Event, relay: Relay?) {
|
||||
if (justVerify(event)) {
|
||||
justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun justVerify(event: Event): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!event.hasValidSignature()) {
|
||||
return if (!event.hasValidSignature()) {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Event failed retest ${event.kind}", e.message ?: "")
|
||||
}
|
||||
return
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun justConsume(event: Event, relay: Relay?) {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
|
||||
@@ -104,11 +104,13 @@ open class Note(val idHex: String) {
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
if (this.event?.id() != event.id()) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.metadata?.invalidateData()
|
||||
liveSet?.metadata?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
@@ -9,6 +10,7 @@ import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
@Stable
|
||||
class RelayInformation(
|
||||
val name: String?,
|
||||
val description: String?,
|
||||
@@ -39,6 +41,7 @@ class RelayInformation(
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class RelayInformationFee(
|
||||
val amount: Int?,
|
||||
val unit: String?,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.CryptoUtils
|
||||
|
||||
object TimeUtils {
|
||||
const val oneMinute = 60
|
||||
const val fiveMinutes = 5 * oneMinute
|
||||
@@ -14,4 +16,6 @@ object TimeUtils {
|
||||
fun oneDayAgo() = now() - oneDay
|
||||
fun eightHoursAgo() = now() - eightHours
|
||||
fun oneWeekAgo() = now() - oneWeek
|
||||
|
||||
fun randomWithinAWeek() = System.currentTimeMillis() / 1000 - CryptoUtils.randomInt(oneWeek)
|
||||
}
|
||||
|
||||
@@ -19,12 +19,19 @@ import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.math.BigDecimal
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
|
||||
@Stable
|
||||
data class ChatroomKey(
|
||||
val users: ImmutableSet<HexKey>
|
||||
)
|
||||
|
||||
@Stable
|
||||
class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
@@ -43,7 +50,7 @@ class User(val pubkeyHex: String) {
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var privateChatrooms = mapOf<User, Chatroom>()
|
||||
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
|
||||
private set
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
@@ -55,6 +62,21 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
|
||||
fun toBestShortFirstName(): String {
|
||||
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
|
||||
|
||||
val names = fullName.split(' ')
|
||||
|
||||
val firstName = if (names[0].length <= 3) {
|
||||
// too short. Remove Dr.
|
||||
"${names[0]} ${names.getOrNull(1) ?: ""}"
|
||||
} else {
|
||||
names[0]
|
||||
}
|
||||
|
||||
return firstName
|
||||
}
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
|
||||
}
|
||||
@@ -174,18 +196,31 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
return privateChatrooms[key] ?: run {
|
||||
val privateChatroom = Chatroom()
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
|
||||
privateChatroom
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
|
||||
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
return getOrCreatePrivateChatroom(key)
|
||||
}
|
||||
|
||||
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
|
||||
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
|
||||
}
|
||||
|
||||
fun addMessage(room: ChatroomKey, msg: Note) {
|
||||
val privateChatroom = getOrCreatePrivateChatroom(room)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.addMessageSync(msg)
|
||||
liveSet?.messages?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
@@ -196,6 +231,10 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun createChatroom(withKey: ChatroomKey) {
|
||||
getOrCreatePrivateChatroom(withKey)
|
||||
}
|
||||
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -309,8 +348,8 @@ class User(val pubkeyHex: String) {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
|
||||
val messagesToUser = privateChatrooms[key] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
|
||||
}
|
||||
@@ -373,8 +412,11 @@ data class RelayInfo(
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Chatroom() {
|
||||
var roomMessages: Set<Note> = setOf()
|
||||
var subject: String? = null
|
||||
var subjectCreatedAt: Long? = null
|
||||
|
||||
@Synchronized
|
||||
fun addMessageSync(msg: Note) {
|
||||
@@ -382,6 +424,13 @@ class Chatroom() {
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
val newSubject = msg.event?.subject()
|
||||
|
||||
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
|
||||
subject = newSubject
|
||||
subjectCreatedAt = msg.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,9 +440,18 @@ class Chatroom() {
|
||||
|
||||
if (msg !in roomMessages) {
|
||||
roomMessages = roomMessages + msg
|
||||
|
||||
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
|
||||
subject = it.event?.subject()
|
||||
subjectCreatedAt = it.createdAt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean {
|
||||
return roomMessages.any { it.author?.pubkeyHex in keySet }
|
||||
}
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
object CryptoUtils {
|
||||
private val secp256k1 = Secp256k1.get()
|
||||
private val libSodium = SodiumAndroid()
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun randomInt(bound: Int): Int {
|
||||
return random.nextInt(bound)
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a 32B "private key" aka random number
|
||||
*/
|
||||
@@ -50,11 +55,15 @@ object CryptoUtils {
|
||||
secp256k1.pubKeyTweakMul(Hex.decode("02") + pubKey, privateKey).copyOfRange(1, 33)
|
||||
|
||||
fun encryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
|
||||
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
|
||||
return encryptNIP04(msg, sharedSecret)
|
||||
val encryptionInfo = encryptNIP04(msg, getSharedSecretNIP04(privateKey, pubKey))
|
||||
return "${encryptionInfo.ciphertext}?iv=${encryptionInfo.nonce}"
|
||||
}
|
||||
|
||||
fun encryptNIP04(msg: String, sharedSecret: ByteArray): String {
|
||||
fun encryptNIP04Json(msg: String, privateKey: ByteArray, pubKey: ByteArray): EncryptedInfo {
|
||||
return encryptNIP04(msg, getSharedSecretNIP04(privateKey, pubKey))
|
||||
}
|
||||
|
||||
fun encryptNIP04(msg: String, sharedSecret: ByteArray): EncryptedInfo {
|
||||
val iv = ByteArray(16)
|
||||
random.nextBytes(iv)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
@@ -62,7 +71,7 @@ object CryptoUtils {
|
||||
val ivBase64 = Base64.getEncoder().encodeToString(iv)
|
||||
val encryptedMsg = cipher.doFinal(msg.toByteArray())
|
||||
val encryptedMsgBase64 = Base64.getEncoder().encodeToString(encryptedMsg)
|
||||
return "$encryptedMsgBase64?iv=$ivBase64"
|
||||
return EncryptedInfo(encryptedMsgBase64, ivBase64, Nip44Version.NIP04.versionCode)
|
||||
}
|
||||
|
||||
fun decryptNIP04(msg: String, privateKey: ByteArray, pubKey: ByteArray): String {
|
||||
@@ -70,10 +79,19 @@ object CryptoUtils {
|
||||
return decryptNIP04(msg, sharedSecret)
|
||||
}
|
||||
|
||||
fun decryptNIP04(encryptedInfo: EncryptedInfo, privateKey: ByteArray, pubKey: ByteArray): String {
|
||||
val sharedSecret = getSharedSecretNIP04(privateKey, pubKey)
|
||||
return decryptNIP04(encryptedInfo.ciphertext, encryptedInfo.nonce, sharedSecret)
|
||||
}
|
||||
|
||||
fun decryptNIP04(msg: String, sharedSecret: ByteArray): String {
|
||||
val parts = msg.split("?iv=")
|
||||
val iv = parts[1].run { Base64.getDecoder().decode(this) }
|
||||
val encryptedMsg = parts.first().run { Base64.getDecoder().decode(this) }
|
||||
return decryptNIP04(parts[0], parts[1], sharedSecret)
|
||||
}
|
||||
|
||||
private fun decryptNIP04(cipher: String, nonce: String, sharedSecret: ByteArray): String {
|
||||
val iv = Base64.getDecoder().decode(nonce)
|
||||
val encryptedMsg = Base64.getDecoder().decode(cipher)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(iv))
|
||||
return String(cipher.doFinal(encryptedMsg))
|
||||
@@ -88,7 +106,8 @@ object CryptoUtils {
|
||||
val nonce = ByteArray(24)
|
||||
random.nextBytes(nonce)
|
||||
|
||||
val cipher = SodiumAndroid().cryptoStreamXChaCha20Xor(
|
||||
val cipher = cryptoStreamXChaCha20Xor(
|
||||
libSodium = libSodium,
|
||||
messageBytes = msg.toByteArray(),
|
||||
nonce = nonce,
|
||||
key = Key.fromBytes(sharedSecret)
|
||||
@@ -100,7 +119,7 @@ object CryptoUtils {
|
||||
return EncryptedInfo(
|
||||
ciphertext = cipherBase64,
|
||||
nonce = nonceBase64,
|
||||
v = Nip44Version.XChaCha20.versionCode
|
||||
v = Nip44Version.NIP24.versionCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,7 +129,8 @@ object CryptoUtils {
|
||||
}
|
||||
|
||||
fun decryptNIP24(encryptedInfo: EncryptedInfo, sharedSecret: ByteArray): String? {
|
||||
return SodiumAndroid().cryptoStreamXChaCha20Xor(
|
||||
return cryptoStreamXChaCha20Xor(
|
||||
libSodium = libSodium,
|
||||
messageBytes = Base64.getDecoder().decode(encryptedInfo.ciphertext),
|
||||
nonce = Base64.getDecoder().decode(encryptedInfo.nonce),
|
||||
key = Key.fromBytes(sharedSecret)
|
||||
@@ -127,6 +147,6 @@ object CryptoUtils {
|
||||
data class EncryptedInfo(val ciphertext: String, val nonce: String, val v: Int)
|
||||
|
||||
enum class Nip44Version(val versionCode: Int) {
|
||||
Reserved(0),
|
||||
XChaCha20(1)
|
||||
NIP04(0),
|
||||
NIP24(1)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
@@ -111,16 +112,51 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
)
|
||||
|
||||
fun createGiftWrapsToMeFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(GiftWrapEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, 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() {
|
||||
// gets everthing about the user logged in
|
||||
accountChannel.typedFilters = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createGiftWrapsToMeFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter(),
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
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.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 {
|
||||
@@ -36,7 +39,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
|
||||
fun createMessagesFromMeFilter(): TypedFilter? {
|
||||
val myPeer = withUser
|
||||
val myPeer = withRoom
|
||||
|
||||
return if (myPeer != null) {
|
||||
TypedFilter(
|
||||
@@ -44,7 +47,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 +56,14 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel()
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
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.GiftWrapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
@@ -21,7 +20,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind, GiftWrapEvent.kind),
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,10 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
open fun consume(event: Event, relay: Relay) {
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.goterl.lazysodium.Sodium
|
||||
import com.goterl.lazysodium.SodiumAndroid
|
||||
import com.goterl.lazysodium.utils.Key
|
||||
|
||||
fun Sodium.crypto_stream_xchacha20_xor_ic(
|
||||
/**
|
||||
* I initially extended these methods from the Sodium and SodiumAndroid classes
|
||||
* But JNI doesn't like it. There is some native method overriding bug
|
||||
* when using Kotlin extensions
|
||||
**/
|
||||
|
||||
fun /*Sodium.*/crypto_stream_xchacha20_xor_ic(
|
||||
libSodium: SodiumAndroid,
|
||||
cipher: ByteArray,
|
||||
message: ByteArray,
|
||||
messageLen: Long,
|
||||
@@ -25,8 +31,8 @@ fun Sodium.crypto_stream_xchacha20_xor_ic(
|
||||
val nonceChaCha = nonce.drop(16).toByteArray()
|
||||
assert(nonceChaCha.size == 8)
|
||||
|
||||
crypto_core_hchacha20(k2, nonce, key, null)
|
||||
return crypto_stream_chacha20_xor_ic(
|
||||
libSodium.crypto_core_hchacha20(k2, nonce, key, null)
|
||||
return libSodium.crypto_stream_chacha20_xor_ic(
|
||||
cipher,
|
||||
message,
|
||||
messageLen,
|
||||
@@ -36,17 +42,19 @@ fun Sodium.crypto_stream_xchacha20_xor_ic(
|
||||
)
|
||||
}
|
||||
|
||||
fun Sodium.crypto_stream_xchacha20_xor(
|
||||
fun /*Sodium.*/crypto_stream_xchacha20_xor(
|
||||
libSodium: SodiumAndroid,
|
||||
cipher: ByteArray,
|
||||
message: ByteArray,
|
||||
messageLen: Long,
|
||||
nonce: ByteArray,
|
||||
key: ByteArray
|
||||
): Int {
|
||||
return crypto_stream_xchacha20_xor_ic(cipher, message, messageLen, nonce, 0, key)
|
||||
return crypto_stream_xchacha20_xor_ic(libSodium, cipher, message, messageLen, nonce, 0, key)
|
||||
}
|
||||
|
||||
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
|
||||
fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor(
|
||||
libSodium: SodiumAndroid,
|
||||
cipher: ByteArray,
|
||||
message: ByteArray,
|
||||
messageLen: Long,
|
||||
@@ -55,6 +63,7 @@ fun SodiumAndroid.cryptoStreamXChaCha20Xor(
|
||||
): Boolean {
|
||||
require(!(messageLen < 0 || messageLen > message.size)) { "messageLen out of bounds: $messageLen" }
|
||||
return crypto_stream_xchacha20_xor(
|
||||
libSodium,
|
||||
cipher,
|
||||
message,
|
||||
messageLen,
|
||||
@@ -63,13 +72,14 @@ fun SodiumAndroid.cryptoStreamXChaCha20Xor(
|
||||
) == 0
|
||||
}
|
||||
|
||||
fun SodiumAndroid.cryptoStreamXChaCha20Xor(
|
||||
fun /*SodiumAndroid.*/cryptoStreamXChaCha20Xor(
|
||||
libSodium: SodiumAndroid,
|
||||
messageBytes: ByteArray,
|
||||
nonce: ByteArray,
|
||||
key: Key
|
||||
): ByteArray? {
|
||||
val mLen = messageBytes.size
|
||||
val cipher = ByteArray(mLen)
|
||||
val sucessful = cryptoStreamXChaCha20Xor(cipher, messageBytes, mLen.toLong(), nonce, key.asBytes)
|
||||
val sucessful = cryptoStreamXChaCha20Xor(libSodium, cipher, messageBytes, mLen.toLong(), nonce, key.asBytes)
|
||||
return if (sucessful) cipher else null
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ class AudioTrackEvent(
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
|
||||
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
|
||||
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
|
||||
|
||||
// fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
|
||||
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -20,7 +20,10 @@ open class BaseTextNoteEvent(
|
||||
fun mentions() = taggedUsers()
|
||||
open fun replyTos() = taggedEvents()
|
||||
|
||||
@Transient
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
|
||||
@Transient
|
||||
private var citedNotesCache: Set<HexKey>? = null
|
||||
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.CryptoUtils
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@Immutable
|
||||
class ChatMessageEvent(
|
||||
@@ -14,22 +16,44 @@ class ChatMessageEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
|
||||
/**
|
||||
* Recepients intended to receive this conversation
|
||||
*/
|
||||
private fun recipientsPubKey() = tags.mapNotNull {
|
||||
fun recipientsPubKey() = tags.mapNotNull {
|
||||
if (it.size > 1 && it[0] == "p") it[1] else null
|
||||
}
|
||||
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun talkingWith(oneSideHex: String): Set<HexKey> {
|
||||
val listedPubKeys = recipientsPubKey()
|
||||
|
||||
val result = if (pubKey == oneSideHex) {
|
||||
listedPubKeys.minus(oneSideHex).toSet()
|
||||
} else {
|
||||
listedPubKeys.plus(pubKey).minus(oneSideHex).toSet()
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
// talking to myself
|
||||
return setOf(pubKey)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey {
|
||||
return ChatroomKey(talkingWith(toRemove).toImmutableSet())
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 14
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
to: List<String>? = null,
|
||||
subject: String? = null,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String? = null,
|
||||
@@ -62,6 +86,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 +97,7 @@ class ChatMessageEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatroomKeyable {
|
||||
fun chatroomKey(toRemove: HexKey): ChatroomKey
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class ContactListEvent(
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This function is only used by the user logged in
|
||||
// But it is used all the time.
|
||||
|
||||
@delegate:Transient
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
@@ -35,18 +37,22 @@ class ContactListEvent(
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@delegate:Transient
|
||||
val verifiedFollowTagSet: Set<String> by lazy {
|
||||
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
@delegate:Transient
|
||||
val verifiedFollowGeohashSet: Set<String> by lazy {
|
||||
unverifiedFollowGeohashSet().map { it.lowercase() }.toSet()
|
||||
}
|
||||
|
||||
@delegate:Transient
|
||||
val verifiedFollowCommunitySet: Set<String> by lazy {
|
||||
unverifiedFollowAddressSet().toSet()
|
||||
}
|
||||
|
||||
@delegate:Transient
|
||||
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ open class Event(
|
||||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
|
||||
}
|
||||
|
||||
override fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
|
||||
|
||||
override fun zapraiserAmount() = tags.firstOrNull() {
|
||||
(it.size > 1 && it[0] == "zapraiser")
|
||||
}?.get(1)?.toLongOrNull()
|
||||
@@ -210,12 +212,12 @@ open class Event(
|
||||
): Event {
|
||||
val jsonObject = json.asJsonObject
|
||||
return Event(
|
||||
id = jsonObject.get("id").asString,
|
||||
pubKey = jsonObject.get("pubkey").asString,
|
||||
id = jsonObject.get("id").asString.intern(),
|
||||
pubKey = jsonObject.get("pubkey").asString.intern(),
|
||||
createdAt = jsonObject.get("created_at").asLong,
|
||||
kind = jsonObject.get("kind").asInt,
|
||||
tags = jsonObject.get("tags").asJsonArray.map {
|
||||
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
|
||||
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString.intern() }
|
||||
},
|
||||
content = jsonObject.get("content").asString,
|
||||
sig = jsonObject.get("sig").asString
|
||||
@@ -223,6 +225,26 @@ open class Event(
|
||||
}
|
||||
}
|
||||
|
||||
private class GossipDeserializer : JsonDeserializer<Gossip> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type?,
|
||||
context: JsonDeserializationContext?
|
||||
): Gossip {
|
||||
val jsonObject = json.asJsonObject
|
||||
return Gossip(
|
||||
id = jsonObject.get("id")?.asString?.intern(),
|
||||
pubKey = jsonObject.get("pubkey")?.asString?.intern(),
|
||||
createdAt = jsonObject.get("created_at")?.asLong,
|
||||
kind = jsonObject.get("kind")?.asInt,
|
||||
tags = jsonObject.get("tags")?.asJsonArray?.mapNotNull {
|
||||
it?.asJsonArray?.mapNotNull { s -> if (s?.isJsonNull != false) null else s.asString.intern() }
|
||||
},
|
||||
content = jsonObject.get("content")?.asString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class EventSerializer : JsonSerializer<Event> {
|
||||
override fun serialize(
|
||||
src: Event,
|
||||
@@ -254,6 +276,38 @@ open class Event(
|
||||
}
|
||||
}
|
||||
|
||||
private class GossipSerializer : JsonSerializer<Gossip> {
|
||||
override fun serialize(
|
||||
src: Gossip,
|
||||
typeOfSrc: Type?,
|
||||
context: JsonSerializationContext?
|
||||
): JsonElement {
|
||||
return JsonObject().apply {
|
||||
src.id?.let { addProperty("id", it) }
|
||||
src.pubKey?.let { addProperty("pubkey", it) }
|
||||
src.createdAt?.let { addProperty("created_at", it) }
|
||||
src.kind?.let { addProperty("kind", it) }
|
||||
src.tags?.let {
|
||||
add(
|
||||
"tags",
|
||||
JsonArray().also { jsonTags ->
|
||||
it.forEach { tag ->
|
||||
jsonTags.add(
|
||||
JsonArray().also { jsonTagElement ->
|
||||
tag.forEach { tagElement ->
|
||||
jsonTagElement.add(tagElement)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
src.content?.let { addProperty("content", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
@@ -275,6 +329,8 @@ open class Event(
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(Event::class.java, EventSerializer())
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(Gossip::class.java, GossipSerializer())
|
||||
.registerTypeAdapter(Gossip::class.java, GossipDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
|
||||
@@ -13,34 +13,6 @@ class EventFactory {
|
||||
content: String,
|
||||
sig: String,
|
||||
lenient: Boolean
|
||||
): Event {
|
||||
val internedTags = tags.map {
|
||||
it.map {
|
||||
it.intern()
|
||||
}
|
||||
}
|
||||
|
||||
return internedCreate(
|
||||
id = id.intern(),
|
||||
pubKey = pubKey.intern(),
|
||||
createdAt = createdAt,
|
||||
kind = kind,
|
||||
tags = internedTags,
|
||||
content = content,
|
||||
sig = sig,
|
||||
lenient = lenient
|
||||
)
|
||||
}
|
||||
|
||||
fun internedCreate(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: String,
|
||||
lenient: Boolean
|
||||
) = when (kind) {
|
||||
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -56,6 +56,7 @@ interface EventInterface {
|
||||
|
||||
fun zapAddress(): String?
|
||||
fun isSensitive(): Boolean
|
||||
fun subject(): String?
|
||||
fun zapraiserAmount(): Long?
|
||||
|
||||
fun taggedAddresses(): List<ATag>
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.CryptoUtils
|
||||
import com.vitorpamplona.amethyst.service.EncryptedInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip44Version
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
@Immutable
|
||||
@@ -19,9 +20,10 @@ class GiftWrapEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient
|
||||
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
|
||||
|
||||
fun cachedGossip(privKey: ByteArray): Event? {
|
||||
fun cachedGift(privKey: ByteArray): Event? {
|
||||
val hex = privKey.toHexKey()
|
||||
if (cachedInnerEvent.contains(hex)) return cachedInnerEvent[hex]
|
||||
|
||||
@@ -41,14 +43,13 @@ class GiftWrapEvent(
|
||||
if (content.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
|
||||
val toDecrypt = gson.fromJson(content, EncryptedInfo::class.java)
|
||||
|
||||
val toDecrypt = gson.fromJson<EncryptedInfo>(
|
||||
content,
|
||||
EncryptedInfo::class.java
|
||||
)
|
||||
|
||||
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
|
||||
return when (toDecrypt.v) {
|
||||
Nip44Version.NIP04.versionCode -> CryptoUtils.decryptNIP04(toDecrypt, privKey, pubKey.hexToByteArray())
|
||||
Nip44Version.NIP24.versionCode -> CryptoUtils.decryptNIP24(toDecrypt, privKey, pubKey.hexToByteArray())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
@@ -63,7 +64,7 @@ class GiftWrapEvent(
|
||||
fun create(
|
||||
event: Event,
|
||||
recipientPubKey: HexKey,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
createdAt: Long = TimeUtils.randomWithinAWeek()
|
||||
): GiftWrapEvent {
|
||||
val privateKey = CryptoUtils.privkeyCreate() // GiftWrap is always a random key
|
||||
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, recipientPubKey.hexToByteArray())
|
||||
|
||||
@@ -8,14 +8,28 @@ class NIP24Factory {
|
||||
fun createMsgNIP24(
|
||||
msg: String,
|
||||
to: List<HexKey>,
|
||||
from: ByteArray
|
||||
from: ByteArray,
|
||||
subject: String? = null,
|
||||
replyTos: List<String>? = null,
|
||||
mentions: List<String>? = null,
|
||||
zapReceiver: String? = null,
|
||||
markAsSensitive: Boolean = false,
|
||||
zapRaiserAmount: Long? = null,
|
||||
geohash: String? = null
|
||||
): List<GiftWrapEvent> {
|
||||
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
|
||||
|
||||
val senderMessage = ChatMessageEvent.create(
|
||||
msg = msg,
|
||||
to = to,
|
||||
privateKey = from
|
||||
privateKey = from,
|
||||
subject = subject,
|
||||
replyTos = replyTos,
|
||||
mentions = mentions,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = markAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash
|
||||
)
|
||||
|
||||
return to.plus(senderPublicKey).map {
|
||||
@@ -29,4 +43,46 @@ class NIP24Factory {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createReactionWithinGroup(content: String, originalNote: EventInterface, to: List<HexKey>, from: ByteArray): List<GiftWrapEvent> {
|
||||
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
|
||||
|
||||
val senderReaction = ReactionEvent.create(
|
||||
content,
|
||||
originalNote,
|
||||
from
|
||||
)
|
||||
|
||||
return to.plus(senderPublicKey).map {
|
||||
GiftWrapEvent.create(
|
||||
event = SealedGossipEvent.create(
|
||||
event = senderReaction,
|
||||
encryptTo = it,
|
||||
privateKey = from
|
||||
),
|
||||
recipientPubKey = it
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createReactionWithinGroup(emojiUrl: EmojiUrl, originalNote: EventInterface, to: List<HexKey>, from: ByteArray): List<GiftWrapEvent> {
|
||||
val senderPublicKey = CryptoUtils.pubkeyCreate(from).toHexKey()
|
||||
|
||||
val senderReaction = ReactionEvent.create(
|
||||
emojiUrl,
|
||||
originalNote,
|
||||
from
|
||||
)
|
||||
|
||||
return to.plus(senderPublicKey).map {
|
||||
GiftWrapEvent.create(
|
||||
event = SealedGossipEvent.create(
|
||||
event = senderReaction,
|
||||
encryptTo = it,
|
||||
privateKey = from
|
||||
),
|
||||
recipientPubKey = it
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.TimeUtils
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.CryptoUtils
|
||||
import com.vitorpamplona.amethyst.service.HexValidator
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Immutable
|
||||
class PrivateDmEvent(
|
||||
@@ -17,7 +19,7 @@ class PrivateDmEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable {
|
||||
/**
|
||||
* This may or may not be the actual recipient's pub key. The event is intended to look like a
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
@@ -40,6 +42,10 @@ class PrivateDmEvent(
|
||||
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
|
||||
}
|
||||
|
||||
override fun chatroomKey(toRemove: String): ChatroomKey {
|
||||
return ChatroomKey(persistentSetOf(talkingWith(toRemove)))
|
||||
}
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.CryptoUtils
|
||||
import com.vitorpamplona.amethyst.service.EncryptedInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip44Version
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
@Immutable
|
||||
@@ -20,6 +21,7 @@ class SealedGossipEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient
|
||||
private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
|
||||
|
||||
fun cachedGossip(privKey: ByteArray): Event? {
|
||||
@@ -35,6 +37,7 @@ class SealedGossipEvent(
|
||||
fun unseal(privKey: ByteArray): Gossip? = try {
|
||||
plainContent(privKey)?.let { gson.fromJson(it, Gossip::class.java) }
|
||||
} catch (e: Exception) {
|
||||
Log.w("GossipEvent", "Fail to decrypt or parse Gossip", e)
|
||||
null
|
||||
}
|
||||
|
||||
@@ -42,16 +45,15 @@ class SealedGossipEvent(
|
||||
if (content.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privKey, pubKey.hexToByteArray())
|
||||
val toDecrypt = gson.fromJson(content, EncryptedInfo::class.java)
|
||||
|
||||
val toDecrypt = gson.fromJson<EncryptedInfo>(
|
||||
content,
|
||||
EncryptedInfo::class.java
|
||||
)
|
||||
|
||||
return CryptoUtils.decryptNIP24(toDecrypt, sharedSecret)
|
||||
return when (toDecrypt.v) {
|
||||
Nip44Version.NIP04.versionCode -> CryptoUtils.decryptNIP04(toDecrypt, privKey, pubKey.hexToByteArray())
|
||||
Nip44Version.NIP24.versionCode -> CryptoUtils.decryptNIP24(toDecrypt, privKey, pubKey.hexToByteArray())
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
Log.w("GossipEvent", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -73,7 +75,7 @@ class SealedGossipEvent(
|
||||
gossip: Gossip,
|
||||
encryptTo: HexKey,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = TimeUtils.now()
|
||||
createdAt: Long = TimeUtils.randomWithinAWeek()
|
||||
): SealedGossipEvent {
|
||||
val sharedSecret = CryptoUtils.getSharedSecretNIP24(privateKey, encryptTo.hexToByteArray())
|
||||
|
||||
@@ -105,7 +107,7 @@ class Gossip(
|
||||
fun mergeWith(event: SealedGossipEvent): Event {
|
||||
val newPubKey = pubKey?.ifBlank { null } ?: event.pubKey
|
||||
val newCreatedAt = if (createdAt != null && createdAt > 1000) createdAt else event.createdAt
|
||||
val newKind = kind ?: 0
|
||||
val newKind = kind ?: -1
|
||||
val newTags = (tags ?: emptyList()).plus(event.tags)
|
||||
val newContent = content ?: ""
|
||||
val newID = id?.ifBlank { null } ?: Event.generateId(newPubKey, newCreatedAt, newKind, newTags, newContent).toHexKey()
|
||||
|
||||
@@ -19,8 +19,6 @@ class TextNoteEvent(
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
|
||||
|
||||
fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1)
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -21,7 +21,7 @@ object Tlv {
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val l = rest[1].toUByte().toInt()
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
|
||||
if (v.size < l) continue
|
||||
|
||||
+85
-18
@@ -5,14 +5,21 @@ 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
|
||||
@@ -23,14 +30,72 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
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
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
|
||||
return when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
event.cachedGift(account.keyPair.privKey)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.keyPair.privKey)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
}
|
||||
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 chatEvent = unwrapAndConsume(giftWrap, account = acc)
|
||||
|
||||
if (chatEvent is ChatMessageEvent && acc.keyPair.privKey != null) {
|
||||
val chatNote = LocalCache.notes[chatEvent.id] ?: return
|
||||
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.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(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,19 +111,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +173,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ object NotificationUtils {
|
||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||
|
||||
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel = NotificationChannel(
|
||||
@@ -41,7 +41,7 @@ object NotificationUtils {
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel = NotificationChannel(
|
||||
|
||||
@@ -202,7 +202,9 @@ fun uriToRoute(uri: String?): String? {
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> {
|
||||
if (nip19.kind == PrivateDmEvent.kind) {
|
||||
"Room/${nip19.author}"
|
||||
nip19.author?.let {
|
||||
"RoomByPubkey/$it"
|
||||
}
|
||||
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
@@ -67,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -300,6 +302,7 @@ private fun RenderSearchResults(
|
||||
if (searchBarViewModel.isSearching) {
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsState()
|
||||
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -320,7 +323,12 @@ private fun RenderSearchResults(
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(item, accountViewModel) {
|
||||
nav("Room/${item.pubkeyHex}")
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
|
||||
accountViewModel.userProfile().createChatroom(withKey)
|
||||
nav("Room/${withKey.hashCode()}")
|
||||
}
|
||||
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +88,12 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.PollIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
@@ -110,17 +112,21 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun NewPostView(
|
||||
onClose: () -> Unit,
|
||||
baseReplyTo: Note? = null,
|
||||
quote: Note? = null,
|
||||
enableMessageInterface: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val account = remember(accountViewModel) { accountViewModel.account }
|
||||
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
postViewModel.wantsDirectMessage = enableMessageInterface
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRelaysDialog by remember {
|
||||
@@ -136,8 +142,6 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
@@ -254,74 +258,23 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = {
|
||||
postViewModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
if (enableMessageInterface) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
SendDirectMessageTo(postViewModel = postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
MessageField(postViewModel)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
postViewModel.pollOptions[postViewModel.pollOptions.size] =
|
||||
""
|
||||
},
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colors.placeholderText
|
||||
),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
PollField(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,6 +482,89 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollField(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
postViewModel.pollOptions[postViewModel.pollOptions.size] =
|
||||
""
|
||||
},
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colors.placeholderText
|
||||
),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun MessageField(
|
||||
postViewModel: NewPostViewModel
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
delay(200)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = {
|
||||
postViewModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.what_s_on_your_mind),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
@@ -581,6 +617,86 @@ fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_message_to),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.toUsers,
|
||||
onValueChange = {
|
||||
postViewModel.updateToUsers(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_message_to_caption),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colors.primary
|
||||
),
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_message_subject),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.W500
|
||||
)
|
||||
|
||||
MyTextField(
|
||||
value = postViewModel.subject,
|
||||
onValueChange = {
|
||||
postViewModel.updateSubject(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_message_subject_caption),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
visualTransformation = UrlUserTagTransformation(
|
||||
MaterialTheme.colors.primary
|
||||
),
|
||||
colors = TextFieldDefaults
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FowardZapTo(postViewModel: NewPostViewModel) {
|
||||
Column(
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.AddressableEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -33,9 +34,17 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
FORWARD_ZAPS,
|
||||
TO_USERS
|
||||
}
|
||||
|
||||
@Stable
|
||||
open class NewPostViewModel() : ViewModel() {
|
||||
var account: Account? = null
|
||||
var requiresNIP24: Boolean = false
|
||||
|
||||
var originalNote: Note? = null
|
||||
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
@@ -48,7 +57,12 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
var userSuggestionsMainMessage: Boolean? = null
|
||||
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||
|
||||
// DMs
|
||||
var wantsDirectMessage by mutableStateOf(false)
|
||||
var toUsers by mutableStateOf(TextFieldValue(""))
|
||||
var subject by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
@@ -91,6 +105,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var wantsZapraiser by mutableStateOf(false)
|
||||
var zapRaiserAmount by mutableStateOf<Long?>(null)
|
||||
|
||||
// NIP24 Wrapped DMs / Group messages
|
||||
var nip24 by mutableStateOf(false)
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
@@ -143,6 +160,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 +183,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 +191,85 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, 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
|
||||
} else if (originalNote?.event is ChatMessageEvent) {
|
||||
val receivers = (originalNote?.event as ChatMessageEvent).recipientsPubKey().plus(originalNote?.author?.pubkeyHex).filterNotNull().toSet().toList()
|
||||
|
||||
account?.sendPost(
|
||||
account?.sendNIP24PrivateMessage(
|
||||
message = tagger.message,
|
||||
replyTo = tagger.replyTos,
|
||||
toUsers = receivers,
|
||||
subject = subject.text.ifBlank { null },
|
||||
replyingTo = originalNote!!,
|
||||
mentions = tagger.mentions,
|
||||
tags = null,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapReceiver = zapReceiver,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
relayList = relayList,
|
||||
geohash = 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 {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
@@ -267,11 +329,16 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
toUsers = TextFieldValue("")
|
||||
subject = TextFieldValue("")
|
||||
|
||||
contentToAddUrl = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
|
||||
wantsDirectMessage = false
|
||||
|
||||
wantsPoll = false
|
||||
zapRecipients = mutableStateListOf<HexKey>()
|
||||
pollOptions = newStateMapPollOptions()
|
||||
@@ -316,7 +383,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 +398,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 +449,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 +458,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 +468,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 +490,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 +603,14 @@ open class NewPostViewModel() : ViewModel() {
|
||||
location = null
|
||||
locUtil = null
|
||||
}
|
||||
|
||||
fun toggleNIP04And24() {
|
||||
if (requiresNIP24) {
|
||||
nip24 = true
|
||||
} else {
|
||||
nip24 = !nip24
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class GeohashPrecision(val digits: Int) {
|
||||
|
||||
@@ -16,9 +16,10 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
|
||||
@Composable
|
||||
fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
@@ -26,7 +27,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
var wantsToJoinChannelOrUser by remember {
|
||||
var wantsToSendNewMessage by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
@@ -38,23 +39,25 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
|
||||
NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
if (wantsToJoinChannelOrUser) {
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
if (wantsToSendNewMessage) {
|
||||
NewPostView({ wantsToSendNewMessage = false }, enableMessageInterface = true, accountViewModel = accountViewModel, nav = nav)
|
||||
// JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (isOpen) {
|
||||
OutlinedButton(
|
||||
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
|
||||
onClick = { wantsToSendNewMessage = true; isOpen = false },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_join_channel),
|
||||
text = stringResource(R.string.messages_new_message),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = Font12SP
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,9 +71,10 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_create_channel),
|
||||
text = stringResource(R.string.messages_create_public_chat),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = Font12SP
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = stringResource(R.string.new_channel),
|
||||
contentDescription = stringResource(R.string.messages_create_public_chat),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
|
||||
@@ -169,7 +169,11 @@ private fun DisplayNoteLink(
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
|
||||
route = remember(noteState) {
|
||||
(note.author?.pubkeyHex ?: nip19.hex).let {
|
||||
"RoomByAuthor/$it"
|
||||
}
|
||||
},
|
||||
nav = nav
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
class ChatroomFeedFilter(val withUser: ChatroomKey, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feedKey(): String {
|
||||
return withUser.pubkeyHex
|
||||
return withUser.hashCode().toString()
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
+39
-23
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
@@ -22,7 +23,10 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
|
||||
val privateChatrooms = me.privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
(it.pubkeyHex in followingKeySet || me.hasSentMessagesTo(it)) && !account.isHidden(it)
|
||||
(
|
||||
privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
me.hasSentMessagesTo(it)
|
||||
) && !account.isAllHidden(it.users)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
@@ -64,25 +68,35 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPublicMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
oldList.forEach { oldNote ->
|
||||
if (
|
||||
(newNotePair.key == oldNote.channelHex()) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
|
||||
) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
if (newNotePair.key == oldNote.channelHex()) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
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)
|
||||
) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
@@ -126,23 +140,25 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
return newRelevantPublicMessages
|
||||
}
|
||||
|
||||
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
|
||||
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<ChatroomKey, Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
|
||||
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
|
||||
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
|
||||
val roomUser = roomUserHex?.let { LocalCache.users[it] }
|
||||
val newRelevantPrivateMessages = mutableMapOf<ChatroomKey, Note>()
|
||||
newItems.filter { it.event is ChatroomKeyable }.forEach { newNote ->
|
||||
val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
val room = account.userProfile().privateChatrooms[roomKey]
|
||||
|
||||
if (roomUserHex != null && (newNote.author?.pubkeyHex == me.pubkeyHex || roomUserHex in followingKeySet || me.hasSentMessagesTo(roomUser)) && !account.isHidden(roomUserHex)) {
|
||||
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
|
||||
if (lastNote != null) {
|
||||
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
if (roomKey != null && room != null) {
|
||||
if ((newNote.author?.pubkeyHex == me.pubkeyHex || room.senderIntersects(followingKeySet) || me.hasSentMessagesTo(roomKey)) && !account.isAllHidden(roomKey.users)) {
|
||||
val lastNote = newRelevantPrivateMessages.get(roomKey)
|
||||
if (lastNote != null) {
|
||||
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
|
||||
newRelevantPrivateMessages.put(roomKey, newNote)
|
||||
}
|
||||
} else {
|
||||
newRelevantPrivateMessages.put(roomKey, newNote)
|
||||
}
|
||||
} else {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -21,7 +22,8 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
it.pubkeyHex !in followingKeySet && !me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
privateChatrooms[it]?.senderIntersects(followingKeySet) == false &&
|
||||
!me.hasSentMessagesTo(it) && !account.isAllHidden(it.users)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
@@ -51,15 +53,20 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
var hasUpdated = false
|
||||
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)
|
||||
) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
if (newNotePair.key == oldRoom) {
|
||||
hasUpdated = true
|
||||
if ((newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUpdated) {
|
||||
myNewList = myNewList.plus(newNotePair.value)
|
||||
}
|
||||
}
|
||||
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
@@ -80,26 +87,26 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
|
||||
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<ChatroomKey, Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
|
||||
val newRelevantPrivateMessages = mutableMapOf<ChatroomKey, Note>()
|
||||
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
|
||||
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
|
||||
val roomUser = roomUserHex?.let { LocalCache.users[it] }
|
||||
val roomKey = (newNote.event as? ChatroomKeyable)?.chatroomKey(me.pubkeyHex)
|
||||
val room = account.userProfile().privateChatrooms[roomKey]
|
||||
|
||||
if (roomUserHex != null &&
|
||||
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
|
||||
!account.isHidden(roomUserHex)
|
||||
if (roomKey != null && room != null &&
|
||||
(newNote.author?.pubkeyHex != me.pubkeyHex && room.senderIntersects(followingKeySet) && !me.hasSentMessagesTo(roomKey)) &&
|
||||
!account.isAllHidden(roomKey.users)
|
||||
) {
|
||||
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
|
||||
val lastNote = newRelevantPrivateMessages.get(roomKey)
|
||||
if (lastNote != null) {
|
||||
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
newRelevantPrivateMessages.put(roomKey, newNote)
|
||||
}
|
||||
} else {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
newRelevantPrivateMessages.put(roomKey, newNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
val gRelays = account.convertGlobalRelays()
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
@@ -51,9 +52,10 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
val noteEvent = it.event
|
||||
val isGlobalRelay = it.relays?.any { gRelays.contains(it) } ?: false
|
||||
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
|
||||
(!ignoreAddressables || noteEvent.kind() < 10000) &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
|
||||
((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
(isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) &&
|
||||
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
|
||||
|
||||
@@ -39,6 +39,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.event !is GiftWrapEvent &&
|
||||
it.author !== loggedInUser &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen
|
||||
@@ -213,7 +214,17 @@ fun AppNavigation(
|
||||
Route.Room.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChatroomScreen(
|
||||
userId = it.arguments?.getString("id"),
|
||||
roomId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.RoomByAuthor.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChatroomScreenByAuthor(
|
||||
authorPubKeyHex = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
@@ -234,6 +245,7 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
LoadRedirectScreen(
|
||||
eventId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
|
||||
@@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.CommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -86,6 +85,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoom
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoomByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
|
||||
@@ -152,10 +153,22 @@ private fun RenderTopRouteBar(
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
Route.Room.base -> LoadUser(baseUserHex = id) {
|
||||
Route.RoomByAuthor.base -> LoadRoomByAuthor(authorPubKeyHex = id, accountViewModel) {
|
||||
if (it != null) {
|
||||
ChatroomHeader(
|
||||
baseUser = it,
|
||||
room = it,
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 11.dp),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
Spacer(BottomTopHeight)
|
||||
}
|
||||
}
|
||||
Route.Room.base -> LoadRoom(roomId = id, accountViewModel) {
|
||||
if (it != null) {
|
||||
ChatroomHeader(
|
||||
room = it,
|
||||
modifier = Modifier.padding(vertical = 4.dp, horizontal = 11.dp),
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
|
||||
@@ -16,8 +16,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveNowFeedFilter
|
||||
@@ -125,6 +125,12 @@ sealed class Route(
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
|
||||
)
|
||||
|
||||
object RoomByAuthor : Route(
|
||||
route = "RoomByAuthor/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
|
||||
)
|
||||
|
||||
object Channel : Route(
|
||||
route = "Channel/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
@@ -271,9 +277,9 @@ object MessagesLatestItem : LatestItem() {
|
||||
if (it == null) return false
|
||||
|
||||
val currentUser = account.userProfile().pubkeyHex
|
||||
val room = (it.event as? PrivateDmEvent)?.talkingWith(currentUser)
|
||||
val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser)
|
||||
return if (room != null) {
|
||||
val lastRead = account.loadLastRead("Room/$room")
|
||||
val lastRead = account.loadLastRead("Room/${room.hashCode()}")
|
||||
(it.createdAt() ?: 0) > lastRead
|
||||
} else {
|
||||
false
|
||||
|
||||
@@ -42,16 +42,20 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
|
||||
@@ -99,32 +103,32 @@ fun ChatroomComposeChannelOrUser(
|
||||
if (channelHex != null) {
|
||||
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
|
||||
} else {
|
||||
ChatroomDirectMessage(baseNote, accountViewModel, nav)
|
||||
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatroomDirectMessage(
|
||||
private fun ChatroomPrivateMessages(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val userRoomHex by remember(baseNote) {
|
||||
val userRoom by remember(baseNote) {
|
||||
derivedStateOf {
|
||||
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
|
||||
(baseNote.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
userRoomHex?.let {
|
||||
LoadUser(it) { baseUser ->
|
||||
Crossfade(baseUser) { user ->
|
||||
if (user != null) {
|
||||
UserRoomCompose(baseNote, user, accountViewModel, nav)
|
||||
} else {
|
||||
Box(Modifier.height(Size75dp).fillMaxWidth()) {
|
||||
// Makes sure just a max amount of objects are loaded.
|
||||
}
|
||||
}
|
||||
Crossfade(userRoom) { room ->
|
||||
if (room != null) {
|
||||
UserRoomCompose(baseNote, room, accountViewModel, nav)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.height(Size75dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
// Makes sure just a max amount of objects are loaded.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,7 +198,7 @@ private fun ChannelRoomCompose(
|
||||
channelIdHex = chanHex,
|
||||
channelPicture = channelPicture,
|
||||
channelTitle = { modifier ->
|
||||
ChannelTitleWithBoostInfo(channelName, modifier)
|
||||
ChannelTitleWithLabelInfo(channelName, modifier)
|
||||
},
|
||||
channelLastTime = remember(note) { note.createdAt() },
|
||||
channelLastContent = remember(note) { "$authorName: $description" },
|
||||
@@ -204,10 +208,10 @@ private fun ChannelRoomCompose(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelTitleWithBoostInfo(channelName: String, modifier: Modifier) {
|
||||
val boosted = stringResource(id = R.string.public_chat)
|
||||
private fun ChannelTitleWithLabelInfo(channelName: String, modifier: Modifier) {
|
||||
val label = stringResource(id = R.string.public_chat)
|
||||
val placeHolderColor = MaterialTheme.colors.placeholderText
|
||||
val channelNameAndBoostInfo = remember {
|
||||
val channelNameAndBoostInfo = remember(channelName) {
|
||||
buildAnnotatedString {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
@@ -223,7 +227,7 @@ private fun ChannelTitleWithBoostInfo(channelName: String, modifier: Modifier) {
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
) {
|
||||
append(" $boosted")
|
||||
append(" $label")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,14 +243,14 @@ private fun ChannelTitleWithBoostInfo(channelName: String, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun UserRoomCompose(
|
||||
note: Note,
|
||||
user: User,
|
||||
room: ChatroomKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
val route = remember(user) {
|
||||
"Room/${user.pubkeyHex}"
|
||||
val route = remember(room) {
|
||||
"Room/${room.hashCode()}"
|
||||
}
|
||||
|
||||
val createAt by remember(note) {
|
||||
@@ -269,13 +273,15 @@ private fun UserRoomCompose(
|
||||
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
NonClickableUserPicture(
|
||||
baseUser = user,
|
||||
NonClickableUserPictures(
|
||||
users = room.users,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size55dp
|
||||
)
|
||||
},
|
||||
channelTitle = { UsernameDisplay(user, it) },
|
||||
channelTitle = {
|
||||
RoomNameDisplay(room, it, accountViewModel.userProfile())
|
||||
},
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
hasNewMessages = hasNewMessages,
|
||||
@@ -283,6 +289,116 @@ private fun UserRoomCompose(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
|
||||
val roomSubject by loggedInUser.live().messages.map {
|
||||
it.user.privateChatrooms[room]?.subject
|
||||
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
|
||||
|
||||
Crossfade(targetState = roomSubject, modifier) {
|
||||
if (it != null && it.isNotBlank()) {
|
||||
if (room.users.size > 1) {
|
||||
DisplayRoomSubject(it)
|
||||
} else {
|
||||
DisplayUserAndSubject(room.users.first(), it)
|
||||
}
|
||||
} else {
|
||||
DisplayUserSetAsSubject(room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUserAndSubject(
|
||||
user: HexKey,
|
||||
subject: String
|
||||
) {
|
||||
Row() {
|
||||
Text(
|
||||
text = subject,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = " - ",
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1
|
||||
)
|
||||
LoadUser(baseUserHex = user) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUserSetAsSubject(
|
||||
room: ChatroomKey,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
) {
|
||||
val userList = remember(room) {
|
||||
room.users.toList()
|
||||
}
|
||||
|
||||
if (userList.size == 1) {
|
||||
// Regular Design
|
||||
Row() {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
it?.let {
|
||||
UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row() {
|
||||
userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value ->
|
||||
LoadUser(baseUserHex = value) {
|
||||
it?.let {
|
||||
ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLast) {
|
||||
Text(
|
||||
text = ", ",
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayRoomSubject(roomSubject: String, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
Row() {
|
||||
Text(
|
||||
text = roomSubject,
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShortUsernameDisplay(baseUser: User, weight: Modifier = Modifier, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
val userName by baseUser.live().metadata.map {
|
||||
it.user.toBestShortFirstName()
|
||||
}.distinctUntilChanged().observeAsState(baseUser.toBestShortFirstName())
|
||||
|
||||
Crossfade(targetState = userName, modifier = weight) {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = baseUser.info?.tags,
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchNotificationChanges(
|
||||
note: Note,
|
||||
|
||||
@@ -11,9 +11,11 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
@@ -34,6 +36,7 @@ import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -43,6 +46,7 @@ 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.ChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
@@ -223,7 +227,17 @@ fun NormalChatNote(
|
||||
) {
|
||||
val drawAuthorInfo by remember {
|
||||
derivedStateOf {
|
||||
note.event !is PrivateDmEvent && (innerQuote || !accountViewModel.isLoggedUser(note.author))
|
||||
val noteEvent = note.event
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
false // never shows the user's pictures
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
false // one-on-one, never shows it.
|
||||
} else if (noteEvent is ChatMessageEvent) {
|
||||
// only shows in a group chat.
|
||||
noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex).users.size > 1
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +562,8 @@ private fun StatusRow(
|
||||
) {
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
IncognitoBadge(baseNote)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ChatTimeAgo(baseNote)
|
||||
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
@@ -574,6 +590,29 @@ private fun StatusRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IncognitoBadge(baseNote: Note) {
|
||||
if (baseNote.event is ChatMessageEvent) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
} else if (baseNote.event is PrivateDmEvent) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito_off),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTimeAgo(baseNote: Note) {
|
||||
val nowStr = stringResource(id = R.string.now)
|
||||
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
|
||||
@@ -1180,8 +1181,10 @@ fun routeFor(note: Note, loggedIn: User): String? {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/$it"
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
return "Room/${noteEvent.talkingWith(loggedIn.pubkeyHex)}"
|
||||
} else if (noteEvent is ChatroomKeyable) {
|
||||
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
|
||||
loggedIn.createChatroom(room)
|
||||
return "Room/${room.hashCode()}"
|
||||
} else if (noteEvent is CommunityDefinitionEvent) {
|
||||
return "Community/${note.idHex}"
|
||||
} else {
|
||||
|
||||
@@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -429,7 +430,7 @@ private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onD
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun QuickActionAlertDialog(
|
||||
fun QuickActionAlertDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
buttonIcon: ImageVector,
|
||||
@@ -438,6 +439,62 @@ private fun QuickActionAlertDialog(
|
||||
onClickDoOnce: () -> Unit,
|
||||
onClickDontShowAgain: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
QuickActionAlertDialog(
|
||||
title = title,
|
||||
textContent = textContent,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = buttonIcon,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
buttonText = buttonText,
|
||||
buttonColors = buttonColors,
|
||||
onClickDoOnce = onClickDoOnce,
|
||||
onClickDontShowAgain = onClickDontShowAgain,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickActionAlertDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
buttonIconResource: Int,
|
||||
buttonText: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onClickDoOnce: () -> Unit,
|
||||
onClickDontShowAgain: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
QuickActionAlertDialog(
|
||||
title = title,
|
||||
textContent = textContent,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(buttonIconResource),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
buttonText = buttonText,
|
||||
buttonColors = buttonColors,
|
||||
onClickDoOnce = onClickDoOnce,
|
||||
onClickDontShowAgain = onClickDontShowAgain,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QuickActionAlertDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
icon: @Composable () -> Unit,
|
||||
buttonText: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onClickDoOnce: () -> Unit,
|
||||
onClickDontShowAgain: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -461,10 +518,7 @@ private fun QuickActionAlertDialog(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = buttonIcon,
|
||||
contentDescription = null
|
||||
)
|
||||
icon()
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(buttonText)
|
||||
}
|
||||
|
||||
@@ -515,7 +515,13 @@ private fun BoostWithDialog(
|
||||
}
|
||||
|
||||
if (wantsToQuote != null) {
|
||||
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
|
||||
NewPostView(
|
||||
onClose = { wantsToQuote = null },
|
||||
baseReplyTo = null,
|
||||
quote = wantsToQuote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
BoostReaction(baseNote, grayTint, accountViewModel) {
|
||||
@@ -535,7 +541,13 @@ private fun ReplyReactionWithDialog(
|
||||
}
|
||||
|
||||
if (wantsToReplyTo != null) {
|
||||
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
|
||||
NewPostView(
|
||||
onClose = { wantsToReplyTo = null },
|
||||
baseReplyTo = wantsToReplyTo,
|
||||
quote = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
||||
|
||||
@@ -218,7 +218,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
|
||||
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
LaunchedEffect(accountViewModel, nip47uri) {
|
||||
postViewModel.load()
|
||||
if (nip47uri != null) {
|
||||
try {
|
||||
@@ -384,6 +384,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
onClose()
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
|
||||
@@ -41,12 +41,14 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -176,18 +178,79 @@ fun ClickableUserPicture(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NonClickableUserPicture(
|
||||
baseUser: User,
|
||||
fun NonClickableUserPictures(
|
||||
users: ImmutableSet<HexKey>,
|
||||
size: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = remember { Modifier }
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val myBoxModifier = remember {
|
||||
Modifier.size(size)
|
||||
}
|
||||
|
||||
Box(myBoxModifier, contentAlignment = Alignment.TopEnd) {
|
||||
BaseUserPicture(baseUser, size, accountViewModel, modifier)
|
||||
val userList = remember(users) {
|
||||
users.toList()
|
||||
}
|
||||
|
||||
when (userList.size) {
|
||||
0 -> {}
|
||||
1 -> LoadUser(baseUserHex = userList[0]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier)
|
||||
}
|
||||
}
|
||||
2 -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
3 -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[2]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LoadUser(baseUserHex = userList[0]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[1]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[2]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
|
||||
}
|
||||
}
|
||||
LoadUser(baseUserHex = userList[3]) {
|
||||
it?.let {
|
||||
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,14 +259,11 @@ fun BaseUserPicture(
|
||||
baseUser: User,
|
||||
size: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = remember { Modifier }
|
||||
innerModifier: Modifier = remember { Modifier },
|
||||
outerModifier: Modifier = remember { Modifier.size(size) }
|
||||
) {
|
||||
val myBoxModifier = remember {
|
||||
Modifier.size(size)
|
||||
}
|
||||
|
||||
Box(myBoxModifier, contentAlignment = Alignment.TopEnd) {
|
||||
InnerBaseUserPicture(baseUser, size, accountViewModel, modifier)
|
||||
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
|
||||
InnerBaseUserPicture(baseUser, size, accountViewModel, innerModifier)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayBut
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
val npubDisplay by remember {
|
||||
derivedStateOf {
|
||||
baseUser.pubkeyDisplayHex()
|
||||
@@ -57,9 +57,9 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
|
||||
|
||||
Crossfade(targetState = userMetadata, modifier = weight) {
|
||||
if (it != null) {
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton)
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, weight)
|
||||
NPubDisplay(npubDisplay, weight, fontWeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,27 +71,28 @@ private fun UserNameDisplay(
|
||||
npubDisplay: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
|
||||
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton)
|
||||
UserAndUsernameDisplay(bestDisplayName, tags, bestUserName, modifier, showPlayButton, fontWeight)
|
||||
} else if (bestDisplayName != null) {
|
||||
UserDisplay(bestDisplayName, tags, modifier, showPlayButton)
|
||||
UserDisplay(bestDisplayName, tags, modifier, showPlayButton, fontWeight)
|
||||
} else if (bestUserName != null) {
|
||||
UserDisplay(bestUserName, tags, modifier, showPlayButton)
|
||||
UserDisplay(bestUserName, tags, modifier, showPlayButton, fontWeight)
|
||||
} else {
|
||||
NPubDisplay(npubDisplay, modifier)
|
||||
NPubDisplay(npubDisplay, modifier, fontWeight)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NPubDisplay(npubDisplay: String, modifier: Modifier) {
|
||||
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold) {
|
||||
Text(
|
||||
text = npubDisplay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = fontWeight,
|
||||
modifier = modifier,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,13 +101,14 @@ private fun UserDisplay(
|
||||
bestDisplayName: String,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
@@ -124,13 +126,14 @@ private fun UserAndUsernameDisplay(
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
bestUserName: String,
|
||||
modifier: Modifier,
|
||||
showPlayButton: Boolean = true
|
||||
showPlayButton: Boolean = true,
|
||||
fontWeight: FontWeight = FontWeight.Bold
|
||||
) {
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1
|
||||
)
|
||||
CreateTextWithEmoji(
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
@@ -203,7 +204,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
|
||||
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map {
|
||||
if (it.event is PrivateDmEvent) {
|
||||
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
|
||||
MessageSetCard(it)
|
||||
} else if (it.event is BadgeAwardEvent) {
|
||||
BadgeCard(it)
|
||||
|
||||
@@ -3,20 +3,28 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
|
||||
@Composable
|
||||
fun RefreshingChatroomFeedView(
|
||||
@@ -100,6 +108,36 @@ fun ChatroomFeedLoaded(
|
||||
nav = nav,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
NewSubject(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewSubject(note: Note) {
|
||||
val subject = remember(note) {
|
||||
note.event?.subject()
|
||||
}
|
||||
|
||||
if (subject != null) {
|
||||
NewSubject(newSubject = subject)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewSubject(newSubject: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Divider(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = newSubject,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = Font14SP,
|
||||
modifier = HalfPadding
|
||||
)
|
||||
Divider(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ 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.ui.note.ChatroomHeaderCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -84,16 +84,20 @@ private fun FeedLoaded(
|
||||
LaunchedEffect(key1 = markAsRead.value) {
|
||||
if (markAsRead.value) {
|
||||
for (note in state.feed.value) {
|
||||
note.event?.let {
|
||||
note.event?.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)
|
||||
"Room/${withKey.hashCode()}"
|
||||
} else {
|
||||
val roomUser = (note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.account.userProfile().pubkeyHex)
|
||||
"Room/$roomUser"
|
||||
null
|
||||
}
|
||||
|
||||
accountViewModel.account.markAsRead(route, it.createdAt())
|
||||
route?.let {
|
||||
accountViewModel.account.markAsRead(route, noteEvent.createdAt())
|
||||
}
|
||||
}
|
||||
}
|
||||
markAsRead.value = false
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -73,7 +74,7 @@ fun RefresheableView(
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -55,8 +56,8 @@ class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : Fe
|
||||
}
|
||||
}
|
||||
}
|
||||
class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) {
|
||||
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
|
||||
class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) {
|
||||
class Factory(val user: ChatroomKey, val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrChatRoomFeedViewModel : ViewModel> create(modelClass: Class<NostrChatRoomFeedViewModel>): NostrChatRoomFeedViewModel {
|
||||
return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
|
||||
@Stable
|
||||
class RelayPoolViewModel : ViewModel() {
|
||||
val connectionStatus = RelayPool.live.map {
|
||||
val connectedRelays = it.relays.connectedRelays()
|
||||
|
||||
@@ -20,9 +20,11 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.GiftWrapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.SealedGossipEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
@@ -280,6 +282,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
account.setHideDeleteRequestDialog()
|
||||
}
|
||||
|
||||
val hideNIP24WarningDialog: Boolean
|
||||
get() = account.hideNIP24WarningDialog
|
||||
|
||||
fun dontShowNIP24WarningDialog() {
|
||||
account.setHideNIP24WarningDialog()
|
||||
}
|
||||
|
||||
val hideBlockAlertDialog: Boolean
|
||||
get() = account.hideBlockAlertDialog
|
||||
|
||||
@@ -326,6 +335,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun unwrap(event: GiftWrapEvent): Event? {
|
||||
return account.unwrap(event)
|
||||
}
|
||||
fun unseal(event: SealedGossipEvent): Event? {
|
||||
return account.unseal(event)
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
|
||||
return AccountViewModel(account) as AccountViewModel
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+536
-24
@@ -1,61 +1,116 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
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.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
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.service.model.ChatMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
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.UserCompose
|
||||
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.ButtonBorder
|
||||
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.collections.immutable.toPersistentList
|
||||
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 +119,72 @@ fun ChatroomScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun ChatroomScreenByAuthor(
|
||||
authorPubKeyHex: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (authorPubKeyHex == null) return
|
||||
|
||||
LoadRoomByAuthor(authorPubKeyHex, accountViewModel) {
|
||||
it?.let {
|
||||
PrepareChatroomViewModels(
|
||||
room = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadRoom(roomId: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) {
|
||||
var room by remember(roomId) {
|
||||
mutableStateOf<ChatroomKey?>(null)
|
||||
}
|
||||
|
||||
if (room == null) {
|
||||
LaunchedEffect(key1 = roomId) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newRoom = accountViewModel.userProfile().privateChatrooms.keys.firstOrNull { it.hashCode().toString() == roomId }
|
||||
if (room != newRoom) {
|
||||
room = newRoom
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content(room)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel, content: @Composable (ChatroomKey?) -> Unit) {
|
||||
val room by remember(authorPubKeyHex) {
|
||||
mutableStateOf<ChatroomKey?>(ChatroomKey(persistentSetOf(authorPubKeyHex)))
|
||||
}
|
||||
|
||||
content(room)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val feedViewModel: NostrChatroomFeedViewModel = viewModel(
|
||||
key = baseUser.pubkeyHex + "ChatroomViewModels",
|
||||
key = room.hashCode().toString() + "ChatroomViewModels",
|
||||
factory = NostrChatroomFeedViewModel.Factory(
|
||||
baseUser,
|
||||
room,
|
||||
accountViewModel.account
|
||||
)
|
||||
)
|
||||
|
||||
val newPostModel: NewPostViewModel = viewModel()
|
||||
newPostModel.account = accountViewModel.account
|
||||
newPostModel.requiresNIP24 = room.users.size > 1
|
||||
if (newPostModel.requiresNIP24) {
|
||||
newPostModel.nip24 = true
|
||||
}
|
||||
|
||||
ChatroomScreen(
|
||||
baseUser = baseUser,
|
||||
room = room,
|
||||
feedViewModel = feedViewModel,
|
||||
newPostModel = newPostModel,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -87,7 +194,7 @@ fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatroomScreen(
|
||||
baseUser: User,
|
||||
room: ChatroomKey,
|
||||
feedViewModel: NostrChatroomFeedViewModel,
|
||||
newPostModel: NewPostViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -95,11 +202,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 +219,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 +250,7 @@ fun ChatroomScreen(
|
||||
viewModel = feedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
routeForLastRead = "Room/${baseUser.pubkeyHex}",
|
||||
routeForLastRead = "Room/${room.hashCode()}",
|
||||
onWantsToReply = {
|
||||
replyTo.value = it
|
||||
}
|
||||
@@ -161,15 +268,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 || replyTo.value?.event is ChatMessageEvent) {
|
||||
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 +296,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 = MaterialTheme.colors.primary
|
||||
)
|
||||
} 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 +475,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 +504,224 @@ fun ChatroomHeader(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupChatroomHeader(
|
||||
room: ChatroomKey,
|
||||
modifier: Modifier = StdPadding,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val expanded = remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (expanded.value) {
|
||||
LongRoomHeader(room, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditRoomSubjectButton(room: ChatroomKey, accountViewModel: AccountViewModel) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewSubjectView({ wantsToPost = false }, accountViewModel, room)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { wantsToPost = true },
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
imageVector = Icons.Default.EditNote,
|
||||
contentDescription = stringResource(R.string.edits_the_channel_metadata)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewSubjectView(onClose: () -> Unit, accountViewModel: AccountViewModel, room: ChatroomKey) {
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface {
|
||||
val groupName = remember {
|
||||
mutableStateOf<String>(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "")
|
||||
}
|
||||
val message = remember {
|
||||
mutableStateOf<String>("")
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.account.sendNIP24PrivateMessage(
|
||||
message = message.value,
|
||||
toUsers = room.users.toList(),
|
||||
subject = groupName.value.ifBlank { null },
|
||||
replyingTo = null,
|
||||
mentions = null,
|
||||
wantsToMarkAsSensitive = false
|
||||
)
|
||||
}
|
||||
|
||||
onClose()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.messages_new_message_subject)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = groupName.value,
|
||||
onValueChange = { groupName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_message_subject_caption),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.messages_new_subject_message)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
value = message.value,
|
||||
onValueChange = { message.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.messages_new_subject_message_placeholder),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 10
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongRoomHeader(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val list = remember(room) {
|
||||
room.users.toPersistentList()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 10.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.messages_group_descriptor),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
EditRoomSubjectButton(room, accountViewModel)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = rememberLazyListState()
|
||||
) {
|
||||
itemsIndexed(list, key = { _, item -> item }) { _, item ->
|
||||
LoadUser(baseUserHex = item) {
|
||||
if (it != null) {
|
||||
UserCompose(baseUser = it, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ fun HomeScreen(
|
||||
nav: (String) -> Unit,
|
||||
nip47: String? = null
|
||||
) {
|
||||
var wantsToAddNip47 by remember { mutableStateOf(nip47) }
|
||||
var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) }
|
||||
|
||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
|
||||
|
||||
|
||||
+16
-5
@@ -23,13 +23,14 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChatroomKeyable
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun LoadRedirectScreen(eventId: String?, navController: NavController) {
|
||||
fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
if (eventId == null) return
|
||||
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
@@ -60,13 +61,14 @@ fun LoadRedirectScreen(eventId: String?, navController: NavController) {
|
||||
noteBase?.let {
|
||||
LoadRedirectScreen(
|
||||
baseNote = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadRedirectScreen(baseNote: Note, nav: (String) -> Unit) {
|
||||
fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -81,8 +83,17 @@ fun LoadRedirectScreen(baseNote: Note, nav: (String) -> Unit) {
|
||||
// stay here, loading
|
||||
} else if (event is ChannelCreateEvent) {
|
||||
nav("Channel/${note.idHex}")
|
||||
} else if (event is PrivateDmEvent) {
|
||||
nav("Room/${note.author?.pubkeyHex}")
|
||||
} else if (event is ChatroomKeyable) {
|
||||
note.author?.let {
|
||||
val withKey = (note.event as ChatroomKeyable)
|
||||
.chatroomKey(accountViewModel.userProfile().pubkeyHex)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
accountViewModel.userProfile().createChatroom(withKey)
|
||||
}
|
||||
|
||||
nav("Room/${withKey.hashCode()}")
|
||||
}
|
||||
} else if (channelHex != null) {
|
||||
nav("Channel/$channelHex")
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -18,14 +19,15 @@ import androidx.compose.material.rememberDrawerState
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -74,6 +76,14 @@ fun MainScreen(
|
||||
val navController = rememberNavController()
|
||||
val navState = navController.currentBackStackEntryAsState()
|
||||
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
val currentDrawerState = scaffoldState.drawerState.currentValue
|
||||
LaunchedEffect(key1 = orientation) {
|
||||
if (orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed) {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
}
|
||||
|
||||
val nav = remember(navController) {
|
||||
{ route: String ->
|
||||
scope.launch {
|
||||
|
||||
+8
-1
@@ -93,6 +93,7 @@ fun NotificationScreen(
|
||||
SummaryBar(
|
||||
model = userReactionsStatsModel
|
||||
)
|
||||
|
||||
RefresheableCardView(
|
||||
viewModel = notifFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
@@ -104,6 +105,9 @@ fun NotificationScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Turn this into an Account flag
|
||||
var hasAlreadyAskedNotificationPermissions = false
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun CheckifItNeedsToRequestNotificationPermission() {
|
||||
@@ -112,7 +116,10 @@ fun CheckifItNeedsToRequestNotificationPermission() {
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
)
|
||||
|
||||
if (!notificationPermissionState.status.isGranted) {
|
||||
if (!notificationPermissionState.status.isGranted && !hasAlreadyAskedNotificationPermissions) {
|
||||
hasAlreadyAskedNotificationPermissions = true
|
||||
|
||||
// This will pause the APP, including the connection with relays.
|
||||
LaunchedEffect(notificationPermissionState) {
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ChatroomKey
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -103,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -1540,11 +1542,19 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
|
||||
@Composable
|
||||
private fun MessageButton(user: User, nav: (String) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { nav("Room/${user.pubkeyHex}") },
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex))
|
||||
user.createChatroom(withKey)
|
||||
nav("Room/${withKey.hashCode()}")
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
|
||||
@@ -426,11 +426,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
|
||||
}
|
||||
|
||||
if (wantsToReplyTo != null) {
|
||||
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
|
||||
NewPostView(onClose = { wantsToReplyTo = null }, baseReplyTo = wantsToReplyTo, quote = null, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
if (wantsToQuote != null) {
|
||||
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
|
||||
NewPostView(onClose = { wantsToQuote = null }, baseReplyTo = null, quote = wantsToQuote, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m480,190.27c-60.64,0 -115.3,16.8 -162.53,49.12l36.21,36.52c37.13,-23.2 80.03,-35.1 126.94,-35.1 66.3,0 122.62,23.21 168.98,69.61 46.37,46.4 69.57,102.76 69.57,169.06 0,47.69 -16.31,88.53 -40.31,126.09l36.29,36.64c28.23,-40.58 48.88,-84.84 53.2,-136.95L917.19,505.27v-50.55L768.36,454.73C762.19,380.26 731.62,317.56 676.64,266.64 621.67,215.72 556.12,190.27 480,190.27ZM282.81,267.38c-54.53,50.8 -85.03,113.2 -91.17,187.34L42.81,454.73v50.55h148.83c6.17,74.46 36.74,137.17 91.72,188.09 54.98,50.92 120.52,76.37 196.64,76.37 76.12,0 141.67,-25.45 196.64,-76.37 5.56,-5.15 7.91,-11.84 12.97,-17.23l-34.77,-34.92c-2.23,2.42 -2.92,5.48 -5.27,7.85 -46.4,46.73 -102.76,70.12 -169.06,70.12 -66.3,0 -122.84,-23.39 -169.57,-70.16 -46.73,-46.77 -70.12,-103.34 -70.12,-169.65 0,-66.3 23.39,-122.62 70.16,-168.98 2.64,-2.62 6.05,-3.43 8.75,-5.9zM479.57,288.4c-23.2,0 -42.85,8.47 -58.91,25.43 -5.41,5.71 -8.56,12.32 -12.15,18.75l25.59,25.86c2.44,-7.74 2.96,-16.25 8.87,-22.58 10.05,-10.77 22.37,-16.17 36.95,-16.17 14.58,0 26.92,5.4 37.03,16.17 10.11,10.77 15.16,23.58 15.16,38.44v42.7h-40l115.2,116.33v-75.7c0,-11.45 -3.89,-21.1 -11.68,-28.91 -7.79,-7.81 -17.43,-11.72 -28.95,-11.72h-3.28v-41.41c0,-23.54 -7.99,-43.96 -23.98,-61.25 -15.99,-17.29 -35.94,-25.94 -59.84,-25.94zM396.6,381.72v35.27h-3.28c-11.56,0 -21.13,3.89 -28.75,11.64 -7.62,7.75 -11.45,17.34 -11.45,28.79v128.95c0,11.45 3.87,21.03 11.56,28.71 7.7,7.68 17.3,11.52 28.83,11.52h173.01c11.53,0 21.2,-3.83 29.02,-11.45 6.49,-6.32 8.66,-14.76 9.77,-23.71L431.68,416.99h-3.79v-3.83zM480,489.41c9.09,0 16.77,3.18 23.09,9.49 6.31,6.31 9.49,14 9.49,23.09 0,9.09 -3.18,16.81 -9.49,23.13 -6.31,6.31 -14,9.45 -23.09,9.45 -9.09,0 -16.77,-3.14 -23.09,-9.45 -6.31,-6.31 -9.49,-14.04 -9.49,-23.13 0,-9.09 3.18,-16.77 9.49,-23.09 6.31,-6.31 14,-9.49 23.09,-9.49z"
|
||||
android:strokeWidth="1.11437"/>
|
||||
<path
|
||||
android:pathData="m130.61,152.81 l657.62,662.75"
|
||||
android:strokeWidth="57.2302"
|
||||
android:fillColor="#000000"
|
||||
android:strokeColor="#000000"/>
|
||||
<path
|
||||
android:pathData="m103.8,46.95 l792.46,796.27"
|
||||
android:strokeWidth="57.2302"
|
||||
android:fillColor="#ffffff"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m480,769.74q-114.18,0 -196.64,-76.38 -82.46,-76.38 -91.72,-188.07L42.82,505.29v-50.57L191.64,454.71q9.26,-111.69 91.72,-188.07 82.46,-76.38 196.64,-76.38 114.18,0 196.64,76.38 82.46,76.38 91.72,188.07h148.81v50.57L768.36,505.29q-9.26,111.69 -91.72,188.07 -82.46,76.38 -196.64,76.38zM480.49,719.16q99.46,0 169.06,-70.1 69.6,-70.1 69.6,-169.56 0,-99.46 -69.55,-169.06 -69.55,-69.6 -169,-69.6 -99.46,0 -169.61,69.55 -70.16,69.55 -70.16,169 0,99.46 70.1,169.61 70.1,70.16 169.56,70.16zM393.51,626.58h173.03q17.29,0 29.02,-11.42 11.73,-11.42 11.73,-28.6v-128.96q0,-17.18 -11.68,-28.89 -11.68,-11.71 -28.95,-11.71h-3.26v-41.4q0,-35.3 -23.99,-61.24 -23.99,-25.94 -59.85,-25.94 -34.8,0 -58.89,25.43 -24.09,25.43 -24.09,60.46v42.69h-3.26q-17.33,0 -28.77,11.62 -11.43,11.62 -11.43,28.8v128.96q0,17.18 11.54,28.7 11.54,11.52 28.84,11.52zM480,554.58q-13.63,0 -23.1,-9.47 -9.47,-9.47 -9.47,-23.1 0,-13.63 9.47,-23.1 9.47,-9.47 23.1,-9.47 13.63,0 23.1,9.47 9.47,9.47 9.47,23.1 0,13.63 -9.47,23.1 -9.47,9.47 -23.1,9.47zM427.88,416.99v-42.69q0,-22.29 15.08,-38.45 15.08,-16.16 36.95,-16.16 21.88,0 37.04,16.16 15.17,16.16 15.17,38.45v42.69zM480,522z"
|
||||
android:strokeWidth="1.11437"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m17.06,13c-1.86,0 -3.42,1.33 -3.82,3.1 -0.95,-0.41 -1.82,-0.3 -2.48,-0.01 -0.41,-1.78 -1.97,-3.09 -3.82,-3.09 -2.17,0 -3.94,1.79 -3.94,4s1.77,4 3.94,4c2.06,0 3.74,-1.62 3.9,-3.68 0.34,-0.24 1.23,-0.69 2.32,0.02 0.18,2.05 1.84,3.66 3.9,3.66 2.17,0 3.94,-1.79 3.94,-4s-1.77,-4 -3.94,-4m-10.12,6.86c-1.56,0 -2.81,-1.28 -2.81,-2.86s1.26,-2.86 2.81,-2.86c1.56,0 2.81,1.28 2.81,2.86s-1.25,2.86 -2.81,2.86m10.12,0c-1.56,0 -2.81,-1.28 -2.81,-2.86s1.25,-2.86 2.81,-2.86 2.82,1.28 2.82,2.86 -1.27,2.86 -2.82,2.86m4.94,-9.36h-20v1.5h20zM15.53,2.63c-0.22,-0.49 -0.78,-0.75 -1.31,-0.58l-2.22,0.74 -2.23,-0.74 -0.05,-0.01c-0.53,-0.15 -1.09,0.13 -1.29,0.64l-2.43,6.32h12l-2.44,-6.32z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="m22.11,21.46 l-19.72,-19.73 -1.28,1.27 5.2,5.2 -0.31,0.8h1.11l1.5,1.5h-6.61v1.5h8.11l3.39,3.37c-0.12,0.24 -0.2,0.48 -0.26,0.73 -0.95,-0.41 -1.83,-0.3 -2.48,-0.01 -0.41,-1.78 -1.97,-3.09 -3.82,-3.09 -2.17,0 -3.94,1.79 -3.94,4s1.77,4 3.94,4c2.06,0 3.74,-1.62 3.9,-3.68 0.34,-0.24 1.23,-0.69 2.32,0.02 0.18,2.05 1.84,3.66 3.9,3.66 0.6,0 1.16,-0.14 1.66,-0.39l2.12,2.12zM6.94,19.86c-1.56,0 -2.81,-1.28 -2.81,-2.86s1.26,-2.86 2.81,-2.86c1.56,0 2.81,1.28 2.81,2.86s-1.25,2.86 -2.81,2.86m10.12,0c-1.56,0 -2.81,-1.28 -2.81,-2.86 0,-0.26 0.04,-0.5 0.11,-0.75l3.48,3.48c-0.25,0.08 -0.5,0.13 -0.78,0.13m4.94,-7.86h-6.8l-1.5,-1.5h8.3zM17.06,13c2.17,0 3.94,1.79 3.94,4 0,0.25 -0.03,0.5 -0.07,0.73l-1.09,-1.09c-0.16,-1.3 -1.18,-2.32 -2.46,-2.47l-1.09,-1.08c0.25,-0.06 0.51,-0.09 0.77,-0.09m-4.86,-4 l-4.48,-4.5 0.71,-1.82c0.2,-0.51 0.76,-0.79 1.29,-0.64l0.05,0.01 2.23,0.74 2.22,-0.74c0.53,-0.17 1.1,0.09 1.32,0.58l0.02,0.05 2.44,6.32z"/>
|
||||
</vector>
|
||||
@@ -23,6 +23,8 @@
|
||||
<string name="copy_user_pubkey">Kopieer auteur ID</string>
|
||||
<string name="copy_note_id">Kopieer note ID</string>
|
||||
<string name="broadcast">Verzenden</string>
|
||||
<string name="request_deletion">Verwijdering aanvragen</string>
|
||||
<string name="block_report">Blokkeren / Rapporteren</string>
|
||||
<string name="block_hide_user"><![CDATA[Blokkeer en verberg gebruiker]]></string>
|
||||
<string name="report_spam_scam">Meld spam / scam</string>
|
||||
<string name="report_impersonation">Meld imitatie</string>
|
||||
@@ -33,6 +35,8 @@
|
||||
<string name="login_with_a_private_key_to_like_posts">Login met een privésleutel om berichten te liken</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Geen Zap bedrag. Houdt ingedrukt om te veranderen</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Login met een privésleutel om Zaps te versturen</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_follow">Login met een privésleutel om te volgen</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Login met een privésleutel om te ontvolgen</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Aantal keer bekeken</string>
|
||||
<string name="boost">Boost</string>
|
||||
@@ -44,6 +48,8 @@
|
||||
<string name="and">" en "</string>
|
||||
<string name="in_channel">"in kanaal "</string>
|
||||
<string name="profile_banner">Profielbanner</string>
|
||||
<string name="payment_successful">Betaling gelukt</string>
|
||||
<string name="error_parsing_error_message">Fout bij het parsen van foutberichten</string>
|
||||
<string name="following">" Volgend"</string>
|
||||
<string name="followers">" Volgers"</string>
|
||||
<string name="profile">Profiel</string>
|
||||
@@ -73,6 +79,7 @@
|
||||
<string name="failed_to_upload_the_image">Uploaden afbeelding mislukt</string>
|
||||
<string name="relay_address">Relay adres</string>
|
||||
<string name="posts">Berichten</string>
|
||||
<string name="bytes">Bytes</string>
|
||||
<string name="errors">Errors</string>
|
||||
<string name="home_feed">Startpagina</string>
|
||||
<string name="private_message_feed">Privéberichten</string>
|
||||
@@ -120,6 +127,7 @@
|
||||
<string name="send_a_direct_message">Stuur een privébericht</string>
|
||||
<string name="edits_the_user_s_metadata">Bewerkt de metadata van de gebruiker</string>
|
||||
<string name="follow">Volgen</string>
|
||||
<string name="follow_back">Terugvolgen</string>
|
||||
<string name="unblock">Deblokkeren</string>
|
||||
<string name="copy_user_id">Kopieer gebruiker ID</string>
|
||||
<string name="unblock_user">Deblokkeer gebruiker</string>
|
||||
@@ -216,6 +224,10 @@
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Kan privébericht niet ontsleutelen>\n\nJe werd genoemd in een privégesprek tussen %1$s en %2$s."</string>
|
||||
<string name="quick_action_block_dialog_btn">Blokkeren</string>
|
||||
<string name="quick_action_delete_dialog_btn">Verwijderen</string>
|
||||
<string name="quick_action_block">Blokkeren</string>
|
||||
<string name="quick_action_report">Rapporteren</string>
|
||||
<string name="quick_action_delete_button">Verwijderen</string>
|
||||
<string name="quick_action_dont_show_again_button">Niet meer laten zien</string>
|
||||
<string name="account_switch_add_account_dialog_title">Nieuw account toevoegen</string>
|
||||
@@ -322,14 +334,16 @@
|
||||
<string name="upload_server_imgur_explainer">Imgur kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - vertrouwd</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg kan het bestand aanpassen</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - vertrouwd</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan het bestand aanpassen</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - vertrouwd</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan het bestand aanpassen</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrcheckme">nostrcheck.me - vertrouwd</string>
|
||||
<string name="upload_server_nostrcheckme_explainer">nostrcheck.me kan het bestand aanpassen</string>
|
||||
<string name="upload_server_imgur_nip94">Verifieerbare Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Checkt of Imgur het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
@@ -342,6 +356,8 @@
|
||||
<string name="upload_server_nostrfilesdev_nip94">Verifieerbare Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Checkt of Nostrfiles.dev het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrcheckme_nip94">Verifieerbare nostrcheck.me (NIP-94)</string>
|
||||
<string name="upload_server_nostrcheckme_nip94_explainer">Checkt of nostrcheck.me het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
<string name="upload_server_relays_nip95">Uw relays (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.</string>
|
||||
|
||||
@@ -414,6 +430,7 @@
|
||||
<string name="sats_to_complete">Zapraiser op %1$s. %2$s sats tot doel</string>
|
||||
<string name="read_from_relay">Lees van relay</string>
|
||||
<string name="write_to_relay">Schrijf naar relay</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Er is een fout opgetreden bij het ophalen van de relay-informatie van %1$s</string>
|
||||
<string name="owner">Eigenaar</string>
|
||||
<string name="version">Versie</string>
|
||||
<string name="software">Software</string>
|
||||
@@ -437,6 +454,10 @@
|
||||
<string name="auth">Auth</string>
|
||||
<string name="payment">Betaling</string>
|
||||
|
||||
<string name="cashu">Cashu token</string>
|
||||
<string name="cashu_redeem">Inwisselen</string>
|
||||
<string name="no_lightning_address_set">Geen Lightning-adres ingesteld</string>
|
||||
<string name="copied_token_to_clipboard">Token gekopieerd naar klembord</string>
|
||||
<string name="live_stream_live_tag">LIVE</string>
|
||||
<string name="live_stream_offline_tag">OFFLINE</string>
|
||||
<string name="live_stream_ended_tag">GEËINDIGD</string>
|
||||
@@ -444,5 +465,45 @@
|
||||
|
||||
<string name="live_stream_is_offline">Livestream is offline</string>
|
||||
<string name="live_stream_has_ended">Livestream is geëindigd</string>
|
||||
|
||||
<string name="are_you_sure_you_want_to_log_out">Uitloggen verwijdert al je lokale informatie. Zorg ervoor dat je een back-up hebt van je privésleutels om te voorkomen dat je je account kwijtraakt. Wilt u doorgaan?</string>
|
||||
<string name="followed_tags">Gevolgde tags</string>
|
||||
<string name="relay_setup">Relays</string>
|
||||
<string name="discover_live">Live</string>
|
||||
<string name="discover_community">Community</string>
|
||||
<string name="discover_chat">Chats</string>
|
||||
<string name="community_approved_posts">Goedgekeurde berichten</string>
|
||||
<string name="groups_no_descriptor">Deze groep heeft geen beschrijving of regels. Praat met de eigenaar om er een toe te voegen</string>
|
||||
<string name="community_no_descriptor">Deze community heeft geen beschrijving. Praat met de eigenaar om er een toe te voegen</string>
|
||||
<string name="add_sensitive_content_label">Gevoelige content</string>
|
||||
<string name="add_sensitive_content_description">Voegt waarschuwing voor gevoelige content toe voordat deze content wordt weergegeven</string>
|
||||
<string name="settings">Instellingen</string>
|
||||
<string name="connectivity_type_always">Altijd</string>
|
||||
<string name="connectivity_type_wifi_only">Alleen wifi</string>
|
||||
<string name="connectivity_type_never">Nooit</string>
|
||||
<string name="system">Systeem</string>
|
||||
<string name="light">Licht</string>
|
||||
<string name="dark">Donker</string>
|
||||
<string name="application_preferences">Application preferences</string>
|
||||
<string name="language">Taal</string>
|
||||
<string name="theme">Thema</string>
|
||||
<string name="automatically_load_images_gifs">Automatisch afbeeldingen/gifs laden</string>
|
||||
<string name="automatically_play_videos">Automatisch video\'s afspelen</string>
|
||||
<string name="automatically_show_url_preview">Automatisch URL preview tonen</string>
|
||||
<string name="load_image">Afbeelding laden</string>
|
||||
<string name="spamming_users">Spammers</string>
|
||||
<string name="muted_button">Geen geluid. Klik om voor geluid</string>
|
||||
<string name="mute_button">Geluid. Klik voor geen geluid</string>
|
||||
<string name="search_button">Lokale en externe records doorzoeken</string>
|
||||
<string name="nip05_verified">Nostr-adres is geverifieerd</string>
|
||||
<string name="nip05_failed">Verificatie van Nostr-adres mislukt</string>
|
||||
<string name="nip05_checking">Nostr-adres checken</string>
|
||||
<string name="select_deselect_all">Alles selecteren/deselecteren</string>
|
||||
<string name="default_relays">Standaard</string>
|
||||
<string name="select_a_relay_to_continue">Selecteer een relay om verder te gaan</string>
|
||||
<string name="zap_forward_title">Stuur Zaps door naar:</string>
|
||||
<string name="zap_forward_explainer">Ondersteunende clients sturen zaps door naar het onderstaande LN-adres of gebruikersprofiel in plaats van naar het jouwe</string>
|
||||
<string name="geohash_title">Locatie weergeven als</string>
|
||||
<string name="geohash_explainer">Voegt een Geohash van je locatie toe aan het bericht. Het publiek weet dat je binnen 5 km (3mi) van de huidige locatie bent.</string>
|
||||
<string name="add_sensitive_content_explainer">Voegt een waarschuwing voor gevoelige inhoud toe voordat je inhoud wordt weergegeven. Dit is ideaal voor NSFW-inhoud of inhoud die sommige mensen beledigend of verontrustend vinden.</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -521,4 +521,19 @@
|
||||
<string name="geohash_explainer">Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location</string>
|
||||
|
||||
<string name="add_sensitive_content_explainer">Adds sensitive content warning before showing your content. This is ideal for any NSFW content or content some people may find offensive or disturbing</string>
|
||||
|
||||
<string name="new_feature_nip24_might_not_be_available_title">New Feature</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_description">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.</string>
|
||||
<string name="new_feature_nip24_activate">Activate</string>
|
||||
|
||||
<string name="messages_create_public_chat">Public</string>
|
||||
<string name="messages_new_message">Private</string>
|
||||
<string name="messages_new_message_to">To</string>
|
||||
<string name="messages_new_message_subject">Subject</string>
|
||||
<string name="messages_new_message_subject_caption">Topic of the conversation</string>
|
||||
<string name="messages_new_message_to_caption">"@User1, @User2, @User3"</string>
|
||||
|
||||
<string name="messages_group_descriptor">Members of this group</string>
|
||||
<string name="messages_new_subject_message">Explanation to members</string>
|
||||
<string name="messages_new_subject_message_placeholder">Changing the name for the new goals.</string>
|
||||
</resources>
|
||||
|
||||
+10
@@ -1,9 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
|
||||
class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||
|
||||
@@ -18,5 +22,11 @@ class PushNotificationReceiverService : FirebaseMessagingService() {
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(token)
|
||||
notificationManager().getOrCreateZapChannel(applicationContext)
|
||||
notificationManager().getOrCreateDMChannel(applicationContext)
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ class NIP19ParserTest {
|
||||
assertEquals(1, result?.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nEventParser3() {
|
||||
val result = Nip19.uriToRoute("nostr:nevent1qqsg6gechd3dhzx38n4z8a2lylzgsmmgeamhmtzz72m9ummsnf0xjfspsdmhxue69uhkummn9ekx7mpvwaehxw309ahx7um5wghx77r5wghxgetk93mhxue69uhhyetvv9ujumn0wd68ytnzvuk8wumn8ghj7mn0wd68ytn9d9h82mny0fmkzmn6d9njuumsv93k2trhwden5te0wfjkccte9ehx7um5wghxyctwvsk8wumn8ghj7un9d3shjtnyv9kh2uewd9hs3kqsdn")
|
||||
|
||||
assertEquals(Nip19.Type.EVENT, result?.type)
|
||||
assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex)
|
||||
assertEquals("wss://nos.lol,wss://nostr.oxtr.dev,wss://relay.nostr.bg,wss://nostr.einundzwanzig.space,wss://relay.nostr.band,wss://relay.damus.io", result?.relay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nEventParserInvalidChecksum() {
|
||||
val result = Nip19.uriToRoute("nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj")
|
||||
|
||||
Reference in New Issue
Block a user