Compare commits

..
33 Commits
Author SHA1 Message Date
Vitor Pamplona eec87f017a v0.73.2 2023-08-12 15:18:26 -04:00
Vitor Pamplona 799037502f Correctly displaying/hiding pictures in Chat Compose 2023-08-12 15:17:05 -04:00
Vitor Pamplona f2b727c587 Fixes the PushNotification of GiftWrapped Messages 2023-08-12 15:00:30 -04:00
Vitor Pamplona b255d1827e Forces the creation of Notification Channels to see if they show up on Samsung phones. 2023-08-12 14:59:53 -04:00
Vitor Pamplona d9e01da7e2 Removes unnecessary code 2023-08-12 14:59:14 -04:00
Vitor Pamplona 99a98439cd Uses the right account to decrypt GiftWraps 2023-08-12 14:56:29 -04:00
Vitor PamplonaandGitHub 724e7e2378 Merge pull request #547 from KotlinGeekDev/fix-landscape-issue
Fix menu drawer in landscape mode issue
2023-08-12 14:15:43 -04:00
Vitor Pamplona 0619c9ffac Displays GiftWraps as Messages in the Notification Screen 2023-08-12 14:12:46 -04:00
Vitor Pamplona cb7b51e8d1 BugFix for marking chat as read 2023-08-12 13:50:40 -04:00
Vitor Pamplona c8172265dd v0.73.1 2023-08-12 11:20:13 -04:00
Vitor Pamplona cce9d424bc Fixes green color for incognito mode 2023-08-12 11:20:01 -04:00
Vitor Pamplona 45227b75dc Fixed ProGuard for libsodium 2023-08-12 11:15:20 -04:00
Vitor Pamplona 6eaba7956c Sets releases as pre-release by default. 2023-08-12 10:31:08 -04:00
Vitor Pamplona cc4b94f738 v0.73.0 2023-08-12 10:10:09 -04:00
Vitor Pamplona 423628a104 Adding ways to update the subject 2023-08-12 10:04:15 -04:00
Vitor Pamplona d262b48f31 BugFix: Chat List updating immediately when new messages arrive. 2023-08-11 20:04:37 -04:00
Vitor Pamplona f97a0468cc BugFix for when the private room is with yourself. 2023-08-11 19:47:17 -04:00
Vitor Pamplona b4d87686ac Adds an incognito badge in every msg 2023-08-11 19:46:59 -04:00
Vitor Pamplona 8cbf07c917 Moves GiftCard filter to Account filters and not Chats 2023-08-11 19:46:26 -04:00
Vitor Pamplona 30fc4c35f4 Only requests notification once. 2023-08-11 19:44:34 -04:00
Vitor Pamplona 7912d492e5 Makes sure replies of NIP24 are also NIP24 2023-08-11 19:43:36 -04:00
KotlinGeekDev d921eb6138 Preserve behaviour pre-fix(i.e if a user opened the drawer and landscaped by choice). 2023-08-11 23:32:27 +01:00
KotlinGeekDev 6875fdc4d0 Watch orientation change, and close drawer in landscape mode. 2023-08-11 23:27:04 +01:00
Vitor Pamplona 0bc701f5c9 Adding a way to visualize all members of a group 2023-08-10 21:27:30 -04:00
Vitor PamplonaandGitHub 5de0808b1e Merge pull request #545 from jeremyd/fix_global_relay_selection
fixing global relays selection
2023-08-10 20:06:59 -04:00
Vitor PamplonaandGitHub 9d680c9f82 Merge pull request #546 from Bardesss/main-1
Update strings.xml (NL)
2023-08-10 20:06:06 -04:00
Vitor Pamplona 63fc4c570d Fixed Alby NWC link 2023-08-10 18:04:38 -04:00
Vitor Pamplona ab2fff0194 Support for NIP24- Trustless GiftWrapped Sealed Private Direct Messages and Small Private Groups 2023-08-10 18:04:23 -04:00
BardesssandGitHub c644ba9a3e Update strings.xml (NL)
Added missing strings.
2023-08-10 09:17:32 +00:00
jeremyd 2791873048 fixing global relays selection 2023-08-08 19:42:19 -07:00
Vitor PamplonaandGitHub 89266bc76f Merge pull request #542 from frnandu/main
fix signed byte used for array slice with + added test
2023-08-07 09:14:27 -04:00
Fernando Martins 9573b4abec fix signed byte used for array slice with + added test 2023-08-07 11:56:11 +02:00
Vitor Pamplona ddcff8fa15 don't return reposts and reactions in Search 2023-08-05 10:44:20 -04:00
78 changed files with 2363 additions and 448 deletions
+1 -1
View File
@@ -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
View File
@@ -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 {
+22
View File
@@ -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
@@ -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
}
}
@@ -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> {
@@ -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
@@ -135,7 +135,11 @@ fun ChatroomListScreen(
}
}
HorizontalPager(pageCount = 2, state = pagerState) { page ->
HorizontalPager(
pageCount = 2,
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
ChatroomListFeedView(
viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel,
@@ -1,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)
@@ -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 {
@@ -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>
+10
View File
@@ -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>
+9
View File
@@ -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>
+65 -4
View File
@@ -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://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Kan privébericht niet ontsleutelen&gt;\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>
+15
View File
@@ -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>
@@ -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")