Compare commits

...
55 Commits
Author SHA1 Message Date
Vitor Pamplona f2e9fbebea v0.62.5 2023-06-26 22:42:42 -04:00
Vitor Pamplona 52af109b4e Minor adjustments in threading 2023-06-26 20:38:06 -04:00
Vitor Pamplona 833e6bc3e1 Moves cashu processing to a state class to account for errors. 2023-06-26 20:17:35 -04:00
Vitor Pamplona 9bed6fdb9a v0.62.4 2023-06-26 18:22:07 -04:00
Vitor Pamplona a4a867ebcd Achieves a bigger space if the hide system bars is placed outside the Dialog. 2023-06-26 18:18:04 -04:00
Vitor Pamplona 4da2f18957 Video Dialog in full screen 2023-06-26 17:44:36 -04:00
Vitor Pamplona 4141a52d51 Allows video dialog on streaming 2023-06-26 15:40:13 -04:00
Vitor Pamplona 4dbc71d577 Removes padding from the live header 2023-06-26 15:39:32 -04:00
Vitor Pamplona 3b5d792562 Extracts Cashu logic into a service class. 2023-06-26 15:27:04 -04:00
Vitor Pamplona dd7f3225c6 Preloading channel from LocalCache 2023-06-26 14:59:18 -04:00
Vitor Pamplona 3477e7b8ba No need to remember Modifier 2023-06-26 14:55:55 -04:00
Vitor Pamplona a3782bb459 Remove image from the live event and use only the author picture in the header. 2023-06-26 14:47:22 -04:00
Vitor Pamplona d4003eee04 Adjusts Profile images to crop and not fit 2023-06-26 14:46:56 -04:00
Vitor Pamplona c9bbb66284 Adjusting logout message. 2023-06-26 14:33:21 -04:00
Vitor PamplonaandGitHub 1498fa9868 Merge pull request #474 from greenart7c3/main
add confirmation dialog before logout
2023-06-26 14:27:15 -04:00
Vitor PamplonaandGitHub 26b493aa1d Merge pull request #473 from believethehype/cashuredeem
Melt cashu tokens in Mint directly instead of using webservice
2023-06-26 14:26:44 -04:00
Vitor Pamplona f9d652f849 Performance Improvements 2023-06-26 14:25:46 -04:00
greenart7c3 30b0ee444f add confirmation dialog before logout 2023-06-26 14:38:44 -03:00
Believethehype bf8341ef58 Merge remote-tracking branch 'upstream/main' into cashuredeem 2023-06-26 18:19:01 +02:00
Believethehype f9f1b0d98e Melt cashu tokens in Mint directly instead of using webservice 2023-06-26 18:18:45 +02:00
Vitor Pamplona 743a4c9d87 Using crossfade to show live activities 2023-06-26 09:12:10 -04:00
Vitor PamplonaandGitHub 275baa7497 Merge pull request #472 from believethehype/cashuredeem
Parse and Show Sat Amount from Cashu tokens
2023-06-26 08:44:19 -04:00
Vitor Pamplona ac1fb879c3 Bugfix for not updating user picture and name after a message arrives 2023-06-26 08:39:46 -04:00
Vitor Pamplona 270ad56450 Aligns LIVE marker to the right in the feed. 2023-06-26 08:38:29 -04:00
Believethehype d155241efc Parse and Show Sat Amount from Cashu tokens 2023-06-26 14:32:08 +02:00
Vitor PamplonaandGitHub d61927cbee Merge pull request #471 from believethehype/cashuredeem
Parse Cashu Tokens and provide link to redeem
2023-06-26 06:50:56 -04:00
Vitor PamplonaandGitHub 996b68f9c6 Merge pull request #470 from Bardesss/patch-1
Update strings.xml (NL)
2023-06-26 06:49:19 -04:00
Believethehype f6913663d1 enable autopay, change parsing 2023-06-26 11:57:31 +02:00
Believethehype 58600e13a8 parse cashu tokens 2023-06-26 11:04:31 +02:00
BardesssandGitHub 54e15ad3cd Update strings.xml (NL)
Updated translation
2023-06-26 08:52:35 +00:00
Vitor Pamplona 777c652bf5 v0.62.3 (TEST) 2023-06-25 19:35:00 -04:00
Vitor Pamplona 7fb1473e21 There is no need to invalidate twice. 2023-06-25 19:34:51 -04:00
Vitor Pamplona 95db1ec84f Typo 2023-06-25 19:34:35 -04:00
Vitor Pamplona 7438973a20 Trying to improve rendering speed of chat messages. 2023-06-25 19:18:32 -04:00
Vitor Pamplona fe31855860 Minor performance improvements in reaction row 2023-06-25 19:18:11 -04:00
Vitor Pamplona d7bce4414d Crossfades changes in Reactions and Zaps 2023-06-25 19:17:48 -04:00
Vitor Pamplona b4a81c137c Removes the need for lifecycle awareness here. 2023-06-25 19:16:41 -04:00
Vitor Pamplona a6390f58d9 Removes debug info 2023-06-25 19:16:15 -04:00
Vitor Pamplona ca3f4c3ec6 Moves MaxWidth for Chat bubbles to Shapes 2023-06-25 16:49:33 -04:00
Vitor Pamplona 53bfd40a31 Simplifies hex check 2023-06-25 16:22:17 -04:00
Vitor Pamplona 798be25c43 BugFix for duplicated notes in the LocalCache 2023-06-25 16:21:36 -04:00
Vitor Pamplona ebe1bcedd0 Moves user msg Synchronization to it's own object. 2023-06-25 16:11:21 -04:00
Vitor Pamplona 4731575b87 Less memory intensive Hex Checker 2023-06-25 12:41:21 -04:00
Vitor Pamplona 383e761372 Moves LocalCache's Synchronized block to Concurrent implementations 2023-06-25 12:40:56 -04:00
Vitor Pamplona 54a95e6a4e Moves synchronized block to inner function 2023-06-25 12:38:08 -04:00
Vitor Pamplona 3105ade755 Moves Shape constants to the Shape class 2023-06-25 11:00:41 -04:00
Vitor Pamplona f651034303 Better starting point for the following observer 2023-06-24 18:08:11 -04:00
Vitor Pamplona 7554dc010c Fixes spacing from last change 2023-06-24 18:07:56 -04:00
Vitor Pamplona 70a962062b Minor code and performance improvements. 2023-06-24 18:03:04 -04:00
Vitor Pamplona 5cc7f3bf2b Correct Immutable class 2023-06-24 15:55:21 -04:00
Vitor Pamplona b040e0ca13 Moves OnlineChecker to a singleton class with cache. 2023-06-24 15:53:22 -04:00
Vitor Pamplona 775094356c Fixes new posts not showing up in the feed. 2023-06-24 10:49:34 -04:00
Vitor Pamplona 0771b179b5 Reducing the need to update the event for every two hours. 2023-06-24 10:36:42 -04:00
Vitor Pamplona 8af307e8bb Migrating the UserProfile page to the new ViewModel structure 2023-06-23 22:00:51 -04:00
Vitor Pamplona e6ec3f1ce4 - Adds picture of the User to the Stream header.
- BugFix for the Click on UserPicture event
2023-06-23 20:44:58 -04:00
48 changed files with 1855 additions and 970 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 223
versionName "0.62.2"
versionCode 226
versionName "0.62.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -1134,7 +1134,7 @@ class Account(
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
private fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
}
@@ -18,7 +18,6 @@ class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
@@ -45,15 +44,9 @@ class AntiSpamFilter {
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
liveSpam.invalidateData()
} else {
val spammer = spamMessages.get(hash)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
logOffender(hash, event)
liveSpam.invalidateData()
}
liveSpam.invalidateData()
return true
}
@@ -63,6 +56,16 @@ class AntiSpamFilter {
return false
}
@Synchronized
private fun logOffender(hashCode: Int, event: Event) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
@@ -33,7 +33,7 @@ class PublicChatChannel(idHex: String) : Channel(idHex) {
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return null
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
@@ -65,7 +65,7 @@ class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null } ?: super.profilePicture()
return info?.image()?.ifBlank { null }
}
override fun anyNameStartsWith(prefix: String): Boolean {
@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag.Companion.isATag
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -13,7 +13,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -42,14 +41,12 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
answer
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
}
@@ -84,14 +81,12 @@ object LocalCache {
return null
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
checkNotInMainThread()
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
return notes.get(idHex) ?: run {
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
}
@@ -115,23 +110,15 @@ object LocalCache {
private fun isValidHexNpub(key: String): Boolean {
if (key.contains(":")) return false
return try {
Hex.decode(key).toNpub()
true
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
false
}
return HexValidator.isHex(key)
}
@Synchronized
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = channelFactory(key)
channels.put(key, answer)
answer
val newObject = channelFactory(key)
channels.putIfAbsent(key, newObject) ?: newObject
}
}
@@ -149,16 +136,14 @@ object LocalCache {
}
}
@Synchronized
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
val answer = AddressableNote(key)
addressables.put(key.toTag(), answer)
answer
val newObject = AddressableNote(key)
addressables.putIfAbsent(key.toTag(), newObject) ?: newObject
}
}
@@ -547,8 +532,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -12,6 +12,8 @@ import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
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
@@ -187,34 +189,34 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatroom
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
checkNotInMainThread()
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
}
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@@ -237,6 +239,7 @@ class User(val pubkeyHex: String) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
try {
@@ -373,7 +376,27 @@ data class RelayInfo(
var counter: Long
)
data class Chatroom(var roomMessages: Set<Note>)
class Chatroom() {
var roomMessages: Set<Note> = setOf()
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
}
@Stable
class UserMetadata {
@@ -401,6 +424,7 @@ class UserMetadata {
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
@@ -414,6 +438,23 @@ class UserMetadata {
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
@@ -0,0 +1,93 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
@Immutable
data class CashuToken(
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
)
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(mint, totalAmount, fees, redeemInvoiceAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Reedem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},
onProgress = {
},
onError = onError
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = client.newCall(request).execute()
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Error")
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
}
}
}
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}
@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
@Immutable
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(10)
val fiveMinutes = 1000 * 60 * 5
fun isOnline(url: String?): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online
}
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
}
@@ -55,7 +55,7 @@ open class Event(
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0].equals("zapraiser", true))
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.HexValidator
import fr.acinq.secp256k1.Hex
import nostr.postr.Utils
import nostr.postr.toHex
@@ -23,11 +24,18 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun verifiedRecipientPubKey(): HexKey? {
val recipient = recipientPubKey()
return if (HexValidator.isHex(recipient)) {
recipient
} else {
null
}
}
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
@@ -39,6 +39,7 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
@@ -389,7 +390,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
ClickableUserPicture(baseUser, 55.dp, accountViewModel)
ClickableUserPicture(baseUser, Size55dp, accountViewModel)
Column(
modifier = Modifier
@@ -0,0 +1,156 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CashuPreview(cashutoken: String, accountViewModel: AccountViewModel) {
var cachuData by remember { mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading<CashuToken>()) }
LaunchedEffect(key1 = cashutoken) {
launch(Dispatchers.IO) {
cachuData = CashuProcessor().parse(cashutoken)
}
}
Crossfade(targetState = cachuData) {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> -> Text(
text = "$cashutoken ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
else -> {}
}
}
}
@Composable
fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) {
val lud16 = remember(accountViewModel) {
accountViewModel.account.userProfile().info?.lud16
}
val useWebService = false
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colors.subtleBorder, QuoteBorder)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.cashu),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = stringResource(R.string.cashu),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
token.totalAmount.let {
Text(
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
)
}
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
// Just in case we want to use a webservice instead of directly contacting the mint
if (useWebService) {
val url = "https://redeem.cashu.me?token=$token&lightning=$lud16&autopay=true"
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(context, intent, null)
} else {
if (lud16 != null) {
CashuProcessor().melt(
token,
lud16,
onSuccess = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
},
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
)
} else {
scope.launch {
Toast.makeText(context, "No Lightning Address set", Toast.LENGTH_SHORT).show()
}
}
}
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(stringResource(R.string.cashu_redeem), color = Color.White, fontSize = 20.sp)
}
}
}
}
@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
@Immutable
sealed class GenericLoadable<T> {
@Immutable
class Loading<T> : GenericLoadable<T>()
@Immutable
class Loaded<T>(val loaded: T) : GenericLoadable<T>()
@Immutable
class Empty<T> : GenericLoadable<T>()
@Immutable
class Error<T>(val errorMessage: String) : GenericLoadable<T>()
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.ClickableText
@@ -26,6 +27,8 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.HeadingStyle
@@ -40,7 +43,6 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
@@ -260,7 +262,7 @@ private fun parseUrls(
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, CASHU, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
@@ -284,6 +286,8 @@ private fun RenderWord(
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (word.startsWith("cashuA", true)) {
WordType.CASHU
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -339,6 +343,7 @@ private fun RenderWordWithoutPreview(
WordType.INVOICE -> NormalWord(wordSpace)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.CASHU -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
@@ -369,6 +374,7 @@ private fun RenderWordWithPreview(
WordType.EMOJI -> RenderCustomEmoji(word, state)
WordType.INVOICE -> MayBeInvoicePreview(word)
WordType.WITHDRAW -> MayBeWithdrawal(word)
WordType.CASHU -> CashuPreview(word, accountViewModel)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
@@ -747,7 +753,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
}
loadedLink = LoadedBechLink(returningNote, it)
val newLink = LoadedBechLink(returningNote, it)
launch(Dispatchers.Main) {
loadedLink = newLink
}
}
}
}
@@ -834,7 +844,9 @@ fun HashTag(word: String, nav: (String) -> Unit) {
}
if (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
launch(Dispatchers.Main) {
tagSuffixPair = Pair(myTag, mySuffix)
}
}
}
}
@@ -918,11 +930,15 @@ fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolea
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
loadedTag = LoadedTag(it, null, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(it, null, suffix)
}
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
loadedTag = LoadedTag(null, it, suffix)
launch(Dispatchers.Main) {
loadedTag = LoadedTag(null, it, suffix)
}
}
}
}
@@ -991,25 +1007,23 @@ private fun DisplayUserFromTag(
) {
val route = remember { "User/${baseUser.pubkeyHex}" }
val suffix = remember { "$addedChars " }
val hex = remember { baseUser.pubkeyDisplayHex() }
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName by remember(innerUserState) {
derivedStateOf {
innerUserState?.user?.toBestDisplayName() ?: ""
}
}
val userTags by remember(innerUserState) {
derivedStateOf {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
val meta by baseUser.live().metadata.map {
it.user.info
}.distinctUntilChanged().observeAsState(baseUser.info)
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = userTags
)
Crossfade(targetState = meta) {
val displayName = remember(it) {
it?.bestDisplayName() ?: hex
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = suffix,
maxLines = 1,
route = route,
nav = nav,
tags = it?.tags ?: ImmutableListOfLists()
)
}
}
@@ -50,7 +50,7 @@ fun RobohashAsyncImage(
@Composable
fun RobohashFallbackAsyncImage(
robot: String,
model: String,
model: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -91,28 +91,15 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (model == null) {
RobohashAsyncImage(
robot = robot,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
RobohashFallbackAsyncImage(
robot = robot,
model = model,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
RobohashFallbackAsyncImage(
robot = robot,
model = model,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -10,10 +11,10 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -21,6 +22,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
@@ -36,6 +38,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -50,6 +53,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
@@ -439,12 +443,33 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
val view = LocalView.current
DisposableEffect(key1 = Unit) {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.hide(
android.view.WindowInsets.Type.systemBars()
)
}
onDispose {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.show(
android.view.WindowInsets.Type.systemBars()
)
}
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
val pagerState: PagerState = remember { PagerState() }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
@@ -454,6 +479,18 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
Row(
modifier = Modifier
.padding(10.dp)
@@ -470,18 +507,6 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
@@ -1,14 +1,11 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import okhttp3.Request
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -35,13 +32,13 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val fortyEightHrs = (Date().time / 1000) - 60 * 60 * 48 // hrs
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > fortyEightHrs && noteEvent.status() == "live" && checkIfOnline(noteEvent.streaming())) &&
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
@@ -55,20 +52,3 @@ class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<No
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
fun checkIfOnline(url: String?): Boolean {
if (url.isNullOrBlank()) return false
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
HttpClient.getHttpClient().newCall(request).execute().code == 200
} catch (e: Exception) {
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
@@ -32,7 +32,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val now = Date().time / 1000
val oneMinuteInTheFuture = Date().time / 1000 + (1 * 60) // one minute in the future.
val oneHr = 60 * 60
return collection
@@ -43,7 +43,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
(it.author?.pubkeyHex in followingKeySet || (noteEvent.isTaggedHashes(followingTagSet))) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
((it.event?.createdAt() ?: 0) < now) &&
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
it.isNewThread() &&
(
(noteEvent !is RepostEvent && noteEvent !is GenericRepostEvent) || // not a repost
@@ -1,29 +0,0 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object PeopleListFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun feed(): List<Note> {
val privKey = account.loggedIn.privKey ?: return emptyList()
val lists = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is PeopleListEvent)
}
.toSet()
return lists
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -5,25 +5,17 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var user: User? = null
class UserProfileBookmarksFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
val notes = user.latestBookmarkList?.taggedEvents()?.mapNotNull {
LocalCache.checkGetOrCreateNote(it)
}?.toSet() ?: emptySet()
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
val addresses = user.latestBookmarkList?.taggedAddresses()?.map {
LocalCache.getOrCreateAddressableNote(it)
}?.toSet() ?: emptySet()
@@ -4,23 +4,15 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
class UserProfileConversationsFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
return user?.notes
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
return user.notes
.filter { account.isAcceptable(it) == true && !it.isNewThread() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed() ?: emptyList()
}
}
@@ -8,27 +8,19 @@ import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
var account: Account? = null
var user: User? = null
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
override fun feedKey(): String {
return account?.userProfile()?.pubkeyHex + "-" + user?.pubkeyHex
}
fun loadUserProfile(accountLoggedIn: Account, user: User) {
account = accountLoggedIn
this.user = user
return account.userProfile().pubkeyHex + "-" + user.pubkeyHex
}
override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
return user?.notes
?.plus(longFormNotes)
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.reversed() ?: emptyList()
return user.notes
.plus(longFormNotes)
.filter { account.isAcceptable(it) == true && it.isNewThread() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed() ?: emptyList()
}
}
@@ -5,21 +5,13 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
var user: User? = null
class UserProfileReportsFeedFilter(val user: User) : FeedFilter<Note>() {
override fun feedKey(): String {
return user?.pubkeyHex ?: ""
}
fun loadUserProfile(user: User?) {
this.user = user
return user.pubkeyHex ?: ""
}
override fun feed(): List<Note> {
val myUser = user ?: return emptyList()
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(myUser.pubkeyHex) == true }
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(user.pubkeyHex) == true }
return reportNotes
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@@ -246,8 +247,42 @@ private fun LogoutButton(
acc: AccountInfo,
accountStateViewModel: AccountStateViewModel
) {
var logoutDialog by remember { mutableStateOf(false) }
if (logoutDialog) {
AlertDialog(
title = {
Text(text = stringResource(R.string.log_out))
},
text = {
Text(text = stringResource(R.string.are_you_sure_you_want_to_log_out))
},
onDismissRequest = {
logoutDialog = false
},
confirmButton = {
TextButton(
onClick = {
logoutDialog = false
accountStateViewModel.logOff(acc.npub)
}
) {
Text(text = stringResource(R.string.log_out))
}
},
dismissButton = {
TextButton(
onClick = {
logoutDialog = false
}
) {
Text(text = stringResource(R.string.cancel))
}
}
)
}
IconButton(
onClick = { accountStateViewModel.logOff(acc.npub) }
onClick = { logoutDialog = true }
) {
Icon(
imageVector = Icons.Default.Logout,
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -40,6 +41,7 @@ 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.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.HexKey
@@ -51,6 +53,8 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -63,11 +67,9 @@ fun ChatroomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val isBlank = remember(noteState) {
noteState?.note?.event == null
}
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
if (isBlank) {
BlankNote(Modifier)
@@ -82,7 +84,7 @@ fun ChatroomComposeChannelOrUser(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val channelHex by remember {
val channelHex by remember(baseNote) {
derivedStateOf {
baseNote.channelHex()
}
@@ -101,7 +103,7 @@ private fun ChatroomDirectMessage(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val userRoomHex by remember {
val userRoomHex by remember(baseNote) {
derivedStateOf {
(baseNote.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex)
}
@@ -227,12 +229,23 @@ private fun UserRoomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
val createAt by remember(note) {
derivedStateOf {
note.createdAt()
}
}
val content by remember(note) {
derivedStateOf {
accountViewModel.decrypt(note)
}
}
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
if (hasNewMessages.value != newHasNewMessages) {
hasNewMessages.value = newHasNewMessages
@@ -248,8 +261,8 @@ private fun UserRoomCompose(
)
},
channelTitle = { UsernameDisplay(user, it) },
channelLastTime = remember(note) { note.createdAt() },
channelLastContent = remember(note) { accountViewModel.decrypt(note) },
channelLastTime = createAt,
channelLastContent = content,
hasNewMessages = hasNewMessages,
onClick = { nav(route) }
)
@@ -338,16 +351,28 @@ fun ChannelName(
Row(
modifier = remember { Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) }
) {
Column(remember { Modifier.width(Size55dp) }) {
Column(Size55Modifier) {
channelPicture()
}
Spacer(modifier = DoubleHorzSpacer)
Column(
modifier = remember { Modifier.padding(start = 10.dp).fillMaxWidth() },
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceAround
) {
FirstRow(channelTitle, channelLastTime)
SecondRow(channelLastContent, hasNewMessages)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.padding(bottom = 4.dp) }
) {
FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) })
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
SecondRow(channelLastContent, hasNewMessages, remember { Modifier.weight(1f) })
}
}
}
@@ -359,60 +384,56 @@ fun ChannelName(
}
@Composable
private fun SecondRow(channelLastContent: String?, hasNewMessages: MutableState<Boolean>) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (channelLastContent != null) {
Text(
channelLastContent,
color = MaterialTheme.colors.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = remember { Modifier.weight(1f) }
)
} else {
Text(
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colors.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = remember { Modifier.weight(1f) }
)
}
private fun SecondRow(channelLastContent: String?, hasNewMessages: MutableState<Boolean>, modifier: Modifier) {
if (channelLastContent != null) {
Text(
channelLastContent,
color = MaterialTheme.colors.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
modifier = modifier
)
} else {
Text(
stringResource(R.string.referenced_event_not_found),
color = MaterialTheme.colors.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
}
if (hasNewMessages.value) {
NewItemsBubble()
}
if (hasNewMessages.value) {
NewItemsBubble()
}
}
@Composable
private fun FirstRow(
channelTitle: @Composable (Modifier) -> Unit,
channelLastTime: Long?
channelLastTime: Long?,
modifier: Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.padding(bottom = 4.dp) }
) {
channelTitle(
remember {
Modifier.weight(1f)
}
)
channelTitle(modifier)
TimeAgo(channelLastTime)
}
channelLastTime?.let {
val context = LocalContext.current
val timeAgo = remember(channelLastTime) { timeAgo(channelLastTime, context) }
Text(
timeAgo,
color = MaterialTheme.colors.grayText
)
@Composable
private fun TimeAgo(channelLastTime: Long?) {
if (channelLastTime == null) return
val context = LocalContext.current
val timeAgo by remember(channelLastTime) {
derivedStateOf {
timeAgo(channelLastTime, context)
}
}
Text(
text = timeAgo,
color = MaterialTheme.colors.grayText,
maxLines = 1
)
}
@Composable
@@ -431,6 +452,7 @@ fun NewItemsBubble() {
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier
.wrapContentHeight()
.align(Alignment.Center)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -27,7 +28,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
@@ -50,9 +50,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayInformation
@@ -71,24 +71,23 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size13dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
@OptIn(ExperimentalFoundationApi::class)
@@ -102,30 +101,33 @@ fun ChatroomMessageCompose(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val noteEvent = remember(noteState) { noteState?.note?.event }
val isBlank by baseNote.live().metadata.map {
it.note.event == null
}.observeAsState(baseNote.event == null)
if (noteEvent == null) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
Modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
}
Crossfade(targetState = isBlank) {
if (it) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
BlankNote(
remember {
Modifier.combinedClickable(
onClick = { },
onLongClick = showPopup
)
}
)
}
} else {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
} else {
CheckHiddenChatMessage(
baseNote,
routeForLastRead,
innerQuote,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
@@ -196,32 +198,34 @@ fun LoadedChatMessageCompose(
}
}
if (showHiddenNote) {
HiddenNote(
state.relevantReports,
accountViewModel,
Modifier,
innerQuote,
nav,
onClick = { showReportedNote = true }
)
} else {
val canPreview by remember(state, showReportedNote) {
derivedStateOf {
(!state.isAcceptable && showReportedNote) || state.canPreview
Crossfade(targetState = showHiddenNote) {
if (it) {
HiddenNote(
state.relevantReports,
accountViewModel,
Modifier,
innerQuote,
nav,
onClick = { showReportedNote = true }
)
} else {
val canPreview by remember(state, showReportedNote) {
derivedStateOf {
(!state.isAcceptable && showReportedNote) || state.canPreview
}
}
}
NormalChatNote(
baseNote,
routeForLastRead,
innerQuote,
canPreview,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
NormalChatNote(
baseNote,
routeForLastRead,
innerQuote,
canPreview,
parentBackgroundColor,
accountViewModel,
nav,
onWantsToReply
)
}
}
}
@@ -239,7 +243,7 @@ fun NormalChatNote(
) {
val drawAuthorInfo by remember {
derivedStateOf {
(innerQuote || !accountViewModel.isLoggedUser(note.author)) && note.event !is PrivateDmEvent
note.event !is PrivateDmEvent && (innerQuote || !accountViewModel.isLoggedUser(note.author))
}
}
@@ -298,11 +302,11 @@ fun NormalChatNote(
modifier = modif,
horizontalArrangement = alignment
) {
val availableBubbleSize = remember { mutableStateOf(IntSize.Zero) }
val availableBubbleSize = remember { mutableStateOf(0) }
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = remember {
if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
if (innerQuote) Modifier else ChatBubbleMaxSizeModifier
}
val clickableModifier = remember {
@@ -320,7 +324,9 @@ fun NormalChatNote(
Row(
horizontalArrangement = alignment,
modifier = modif2.onSizeChanged {
availableBubbleSize.value = it
if (availableBubbleSize.value != it.width) {
availableBubbleSize.value = it.width
}
}
) {
Surface(
@@ -362,17 +368,19 @@ private fun RenderBubble(
backgroundBubbleColor: MutableState<Color>,
onWantsToReply: (Note) -> Unit,
canPreview: Boolean,
availableBubbleSize: MutableState<IntSize>,
availableBubbleSize: MutableState<Int>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val bubbleSize = remember { mutableStateOf(IntSize.Zero) }
val bubbleSize = remember { mutableStateOf(0) }
val bubbleModifier = remember {
Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
.onSizeChanged {
bubbleSize.value = it
if (bubbleSize.value != it.width) {
bubbleSize.value = it.width
}
}
}
@@ -404,8 +412,8 @@ private fun MessageBubbleLines(
accountViewModel: AccountViewModel,
onWantsToReply: (Note) -> Unit,
canPreview: Boolean,
bubbleSize: MutableState<IntSize>,
availableBubbleSize: MutableState<IntSize>
bubbleSize: MutableState<Int>,
availableBubbleSize: MutableState<Int>
) {
if (drawAuthorInfo) {
DrawAuthorInfo(
@@ -458,7 +466,7 @@ private fun RenderReplyRow(
) {
val hasReply by remember {
derivedStateOf {
innerQuote && note.replyTo?.lastOrNull() != null
!innerQuote && note.replyTo?.lastOrNull() != null
}
}
@@ -528,23 +536,22 @@ private fun NoteRow(
@Composable
private fun ConstrainedStatusRow(
bubbleSize: MutableState<IntSize>,
availableBubbleSize: MutableState<IntSize>,
bubbleSize: MutableState<Int>,
availableBubbleSize: MutableState<Int>,
content: @Composable () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 5.dp)
.then(
with(LocalDensity.current) {
Modifier.widthIn(
bubbleSize.value.width.toDp(),
availableBubbleSize.value.width.toDp()
)
}
)
modifier = with(LocalDensity.current) {
Modifier
.height(26.dp)
.padding(top = 5.dp)
.widthIn(
bubbleSize.value.toDp(),
availableBubbleSize.value.toDp()
)
}
) {
content()
}
@@ -557,29 +564,30 @@ private fun StatusRow(
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = ReactionRowHeightChat) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
ChatTimeAgo(baseNote)
RelayBadges(baseNote, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
}
}
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = ReactionRowHeightChat) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel)
Spacer(modifier = StdHorzSpacer)
Spacer(modifier = DoubleHorzSpacer)
ReplyReaction(
baseNote = baseNote,
grayTint = MaterialTheme.colors.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSize = Size16dp
iconSize = Size15dp
) {
onWantsToReply(baseNote)
}
Spacer(modifier = StdHorzSpacer)
}
}
}
@@ -799,52 +807,61 @@ private fun DisplayMessageUsername(
DrawPlayName(userDisplayName)
}
@Immutable
data class RelayBadgesState(
val shouldDisplayExpandButton: Boolean,
val noteRelays: ImmutableList<String>,
val noteRelaysSimple: ImmutableList<String>
)
@Composable
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val expanded = remember { mutableStateOf(false) }
RenderRelayList(baseNote, expanded, accountViewModel, nav)
RenderExpandButton(baseNote, expanded) {
ChatRelayExpandButton { expanded.value = true }
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelaysState by baseNote.live().relays.observeAsState()
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteRelays by baseNote.live().relays.map {
it.note.relays
}.observeAsState(baseNote.relays)
val state: RelayBadgesState by remember(noteRelaysState) {
val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3
val noteRelays = noteRelaysState?.note?.relays?.toImmutableList() ?: persistentListOf()
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toImmutableList() ?: persistentListOf()
mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple))
}
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay by remember(noteRelaysState) {
derivedStateOf {
if (expanded) state.noteRelays else state.noteRelaysSimple
FlowRow(StdStartPadding) {
val relaysToDisplay = remember(noteRelays, expanded.value) {
if (expanded.value) noteRelays else noteRelays.take(3)
}
}
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
RenderRelay(it, accountViewModel, nav)
}
}
}
if (state.shouldDisplayExpandButton && !expanded) {
IconButton(
@Composable
fun RenderExpandButton(
baseNote: Note,
expanded: MutableState<Boolean>,
content: @Composable () -> Unit
) {
val showExpandButton by baseNote.live().relays.map {
it.note.relays.size > 3
}.observeAsState(baseNote.relays.size > 3)
if (showExpandButton && !expanded.value) {
content()
}
}
@Composable
fun ChatRelayExpandButton(onClick: () -> Unit) {
IconButton(
modifier = Size15Modifier,
onClick = onClick
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Size15Modifier,
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Size15Modifier,
tint = MaterialTheme.colors.placeholderText
)
}
tint = MaterialTheme.colors.placeholderText
)
}
}
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -28,6 +29,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -109,21 +111,16 @@ fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifie
@Composable
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val isValidNIP05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()?.split("@")?.size == 2
}
}
val nip05 by remember(userState) {
derivedStateOf {
userState?.user?.nip05()
}
}
val nip05 by baseUser.live().metadata.map {
it.user.nip05()
}.observeAsState()
if (isValidNIP05) {
nip05?.let {
DisplayNIP05Line(it, baseUser, columnModifier)
Crossfade(targetState = nip05, modifier = columnModifier) {
if (it != null) {
val isValid = it.split("@").size == 2
if (isValid) {
DisplayNIP05Line(it, baseUser, columnModifier)
}
}
}
}
@@ -131,9 +128,11 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
@Composable
private fun DisplayNIP05Line(nip05: String, baseUser: User, columnModifier: Modifier = Modifier) {
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
DisplayNIP05(nip05, nip05Verified)
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex)
Crossfade(targetState = nip05Verified) {
Row(verticalAlignment = Alignment.CenterVertically) {
DisplayNIP05(nip05, it)
}
}
}
}
@@ -150,7 +149,7 @@ private fun DisplayNIP05(
if (user != "_") {
Text(
text = AnnotatedString(user),
text = remember(nip05) { AnnotatedString(user) },
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -160,7 +159,7 @@ private fun DisplayNIP05(
NIP05VerifiedSymbol(nip05Verified)
ClickableText(
text = AnnotatedString(domain),
text = remember(nip05) { AnnotatedString(domain) },
onClick = { runCatching { uri.openUri("https://$domain") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.slideInVertically
@@ -21,7 +22,6 @@ import androidx.compose.foundation.layout.Spacer
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.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.CircularProgressIndicator
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeight
@@ -502,16 +503,16 @@ fun ReplyReaction(
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.map {
it.note.replies.size
}.distinctUntilChanged().observeAsState()
}.observeAsState(0)
SlidingAnimation(repliesState ?: 0, textColor)
SlidingAnimation(repliesState, textColor)
}
@Composable
@OptIn(ExperimentalAnimationApi::class)
private fun SlidingAnimation(count: Int, textColor: Color) {
private fun SlidingAnimation(baseCount: Int, textColor: Color) {
AnimatedContent<Int>(
targetState = count,
targetState = baseCount,
transitionSpec = {
if (targetState > initialState) {
slideInVertically { -it } with slideOutVertically { it }
@@ -521,10 +522,11 @@ private fun SlidingAnimation(count: Int, textColor: Color) {
}
) { count ->
Text(
text = showCount(count),
text = remember(count) { showCount(count) },
fontSize = Font14SP,
color = textColor,
modifier = HalfStartPadding
modifier = HalfStartPadding,
maxLines = 1
)
}
}
@@ -543,10 +545,10 @@ private fun SlidingAnimation(amount: String, textColor: Color) {
}
) { count ->
Text(
text = amount,
text = count,
fontSize = Font14SP,
color = textColor,
modifier = HalfStartPadding
maxLines = 1
)
}
}
@@ -721,15 +723,19 @@ fun LikeIcon(
launch(Dispatchers.Default) {
val newReactionType = reactionsState?.note?.isReactedBy(accountViewModel.userProfile())?.firstFullChar()
if (reactionType != newReactionType) {
reactionType = newReactionType
launch(Dispatchers.Main) {
reactionType = newReactionType
}
}
}
}
if (reactionType != null) {
RenderReactionType(reactionType!!, iconSize, iconFontSize)
} else {
RenderLikeIcon(iconSize, grayTint)
Crossfade(targetState = reactionType) {
if (it != null) {
RenderReactionType(it, iconSize, iconFontSize)
} else {
RenderLikeIcon(iconSize, grayTint)
}
}
}
@@ -787,7 +793,9 @@ fun LikeText(baseNote: Note, grayTint: Color) {
launch(Dispatchers.Default) {
val newReactionsCount = reactionsState?.note?.countReactions() ?: 0
if (reactionsCount != newReactionsCount) {
reactionsCount = newReactionsCount
launch(Dispatchers.Main) {
reactionsCount = newReactionsCount
}
}
}
}
@@ -919,7 +927,7 @@ fun ZapReaction(
}
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(Modifier.width(3.dp))
Spacer(ModifierWidth3dp)
CircularProgressIndicator(
progress = animateFloatAsState(
@@ -1016,27 +1024,31 @@ private fun ZapIcon(
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(it)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
launch(Dispatchers.Main) {
wasZappedByLoggedInUser = newWasZapped
}
}
}
}
}
}
if (wasZappedByLoggedInUser) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
Crossfade(targetState = wasZappedByLoggedInUser) {
if (it) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
}
}
}
@@ -1055,7 +1067,9 @@ private fun ZapAmountText(
zapsState?.note?.let {
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(it))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
launch(Dispatchers.Main) {
zapAmountTxt = newZapAmount
}
}
}
}
@@ -6,13 +6,13 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@Composable
fun UserCompose(
@@ -39,9 +39,9 @@ fun UserCompose(
modifier = overallModifier,
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, accountViewModel, 55.dp)
UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
@@ -49,7 +49,7 @@ fun UserCompose(
AboutDisplay(baseUser)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
UserActionOptions(baseUser, accountViewModel)
}
}
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.util.Log
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -20,13 +22,11 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -36,38 +36,34 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val authorState by baseNote.live().metadata.map {
it.note.author
}.distinctUntilChanged().observeAsState()
}.observeAsState(baseNote.author)
authorState?.let {
UsernameDisplay(it, weight)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val bestUserName by remember(userState) {
derivedStateOf {
userState?.user?.bestUsername()
}
}
val bestDisplayName by remember(userState) {
derivedStateOf {
userState?.user?.bestDisplayName()
}
}
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
}
}
val tags by remember(userState) {
derivedStateOf {
userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
val userMetadata by baseUser.live().metadata.map {
it.user.info
}.observeAsState(baseUser.info)
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight)
} else {
NPubDisplay(npubDisplay, weight)
}
}
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@Composable
@@ -106,16 +102,18 @@ private fun UserDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
@Composable
@@ -125,22 +123,24 @@ private fun UserAndUsernameDisplay(
bestUserName: String,
modifier: Modifier
) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1
)
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
Row(modifier = modifier) {
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
fontWeight = FontWeight.Bold,
maxLines = 1
)
CreateTextWithEmoji(
text = remember { "@$bestUserName" },
tags = tags,
color = MaterialTheme.colors.placeholderText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
)
Spacer(StdHorzSpacer)
DrawPlayName(bestDisplayName)
}
}
@Composable
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -106,7 +107,7 @@ private fun RenderZapNote(
},
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
UserPicture(baseAuthor, Size55dp, accountViewModel = accountViewModel, nav = nav)
Column(
modifier = remember {
@@ -11,11 +11,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -28,18 +27,19 @@ fun ChatroomListFeedView(
markAsRead: MutableState<Boolean>
) {
RefresheableView(viewModel, true) {
CorssFadeState(viewModel, accountViewModel, nav, markAsRead)
CrossFadeState(viewModel, accountViewModel, nav, markAsRead)
}
}
@Composable
private fun CorssFadeState(
private fun CrossFadeState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
markAsRead: MutableState<Boolean>
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
@@ -197,8 +197,7 @@ private fun FeedLoaded(
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
val defaultModifier = remember {
Modifier
.fillMaxWidth().animateItemPlacement()
Modifier.fillMaxWidth().animateItemPlacement()
}
Row(defaultModifier) {
@@ -81,11 +81,38 @@ class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFil
}
}
class NostrUserProfileNewThreadsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileNewThreadFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileNewThreadsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileNewThreadsFeedViewModel>): NostrUserProfileNewThreadsFeedViewModel {
return NostrUserProfileNewThreadsFeedViewModel(user, account) as NostrUserProfileNewThreadsFeedViewModel
}
}
}
class NostrUserProfileConversationsFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileConversationsFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileConversationsFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileConversationsFeedViewModel>): NostrUserProfileConversationsFeedViewModel {
return NostrUserProfileConversationsFeedViewModel(user, account) as NostrUserProfileConversationsFeedViewModel
}
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrUserProfileReportFeedViewModel(val user: User) : FeedViewModel(UserProfileReportsFeedFilter(user)) {
class Factory(val user: User) : ViewModelProvider.Factory {
override fun <NostrUserProfileReportFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileReportFeedViewModel>): NostrUserProfileReportFeedViewModel {
return NostrUserProfileReportFeedViewModel(user) as NostrUserProfileReportFeedViewModel
}
}
}
class NostrUserProfileBookmarksFeedViewModel(val user: User, val account: Account) : FeedViewModel(UserProfileBookmarksFeedFilter(user, account)) {
class Factory(val user: User, val account: Account) : ViewModelProvider.Factory {
override fun <NostrUserProfileBookmarksFeedViewModel : ViewModel> create(modelClass: Class<NostrUserProfileBookmarksFeedViewModel>): NostrUserProfileBookmarksFeedViewModel {
return NostrUserProfileBookmarksFeedViewModel(user, account) as NostrUserProfileBookmarksFeedViewModel
}
}
}
class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
@@ -89,11 +88,12 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
@@ -116,7 +116,7 @@ fun ChannelScreen(
) {
if (channelId == null) return
var channelBase by remember { mutableStateOf<Channel?>(null) }
var channelBase by remember { mutableStateOf<Channel?>(LocalCache.getChannelIfExists(channelId)) }
LaunchedEffect(channelId) {
withContext(Dispatchers.IO) {
@@ -518,7 +518,7 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var baseChannel by remember { mutableStateOf<Channel?>(LocalCache.channels[channelHex]) }
var baseChannel by remember { mutableStateOf(LocalCache.channels[channelHex]) }
if (baseChannel == null) {
LaunchedEffect(key1 = channelHex) {
@@ -549,40 +549,65 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
Column(
Modifier.fillMaxWidth().clickable {
nav("Channel/${baseChannel.idHex}")
}
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
nav("Channel/${baseChannel.idHex}")
}
}
) {
val channelState by baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState?.channel } ?: return
val streamingUrl by remember(channelState) {
derivedStateOf {
(channel as? LiveActivitiesChannel)?.info?.streaming()
val activity = channel as? LiveActivitiesChannel
val description = activity?.info?.title()
val url = activity?.info?.streaming()
if (url != null) {
ZoomableUrlVideo(url, description = description)
} else {
null
}
}
}
if (streamingUrl != null && showVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) }) {
VideoView(
videoUri = streamingUrl!!,
description = null
ZoomableContentView(
content = streamingUrl!!
)
}
}
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = channel.profilePicture(),
contentDescription = stringResource(R.string.profile_image),
modifier = Modifier
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
channel.creator?.let {
UserPicture(
user = it,
size = Size35dp,
accountViewModel = accountViewModel,
nav = nav
)
}
/*
channel.profilePicture()?.let {
RobohashAsyncImageProxy(
robot = channel.idHex,
model = it,
contentDescription = stringResource(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = Modifier.padding(start = 10.dp)
.width(Size35dp)
.height(Size35dp)
.clip(shape = CircleShape)
)
}
*/
Column(
modifier = Modifier
@@ -592,7 +617,7 @@ fun ChannelHeader(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
channel.toBestDisplayName(),
text = remember(channelState) { channel.toBestDisplayName() },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -619,7 +644,7 @@ fun ChannelHeader(
Row(
modifier = Modifier
.height(Size35dp)
.padding(bottom = 3.dp),
.padding(start = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (channel is PublicChatChannel) {
@@ -701,9 +726,9 @@ private fun LiveChannelActionOptions(
}
@Composable
private fun LiveFlag() {
fun LiveFlag() {
Text(
text = "LIVE",
text = stringResource(id = R.string.live_stream_live_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
@@ -715,6 +740,51 @@ private fun LiveFlag() {
)
}
@Composable
fun EndedFlag() {
Text(
text = stringResource(id = R.string.live_stream_ended_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun OfflineFlag() {
Text(
text = stringResource(id = R.string.live_stream_offline_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
fun ScheduledFlag() {
Text(
text = stringResource(id = R.string.live_stream_planned_tag),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = remember {
Modifier
.clip(SmallBorder)
.drawBehind { drawRect(Color.Black) }
.padding(horizontal = 5.dp)
}
)
}
@Composable
private fun NoteCopyButton(
note: Channel
@@ -71,8 +71,6 @@ fun ChatroomListScreen(
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@@ -36,8 +36,8 @@ import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.dal.checkIfOnline
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedState
@@ -76,6 +76,8 @@ fun HomeScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
liveActivitiesViewModel.invalidateData(true)
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrHomeDataSource.invalidateFilters()
@@ -187,13 +189,11 @@ private fun FeedLoaded(
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
contentPadding = PaddingValues(),
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
CheckIfOnline(item) {
CheckIfLiveActivityIsOnline(item) {
ChannelHeader(
channelHex = remember { item.idHex },
showVideo = false,
@@ -210,18 +210,20 @@ private fun FeedLoaded(
}
@Composable
fun CheckIfOnline(note: Note, whenOnline: @Composable () -> Unit) {
fun CheckIfLiveActivityIsOnline(note: Note, whenOnline: @Composable () -> Unit) {
val noteState by note.live().metadata.observeAsState()
var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
online = checkIfOnline((note.event as? LiveActivitiesEvent)?.streaming())
online = OnlineChecker.isOnline((note.event as? LiveActivitiesEvent)?.streaming())
}
}
if (online) {
whenOnline()
Crossfade(targetState = online) {
if (it) {
whenOnline()
}
}
}
@@ -76,9 +76,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
@@ -104,7 +101,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
@Composable
@@ -116,7 +112,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
if (userBase == null) {
LaunchedEffect(userId) {
// waits to resolve.
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
if (newUserBase != userBase) {
userBase = newUserBase
@@ -166,33 +162,66 @@ fun PrepareViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (
)
)
val threadsViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "UserProfileNewThreadsFeedViewModel",
factory = NostrUserProfileNewThreadsFeedViewModel.Factory(
baseUser,
accountViewModel.account
)
)
val repliesViewModel: NostrUserProfileConversationsFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "UserProfileConversationsFeedViewModel",
factory = NostrUserProfileConversationsFeedViewModel.Factory(
baseUser,
accountViewModel.account
)
)
val bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "UserProfileBookmarksFeedViewModel",
factory = NostrUserProfileBookmarksFeedViewModel.Factory(
baseUser,
accountViewModel.account
)
)
val reportsFeedViewModel: NostrUserProfileReportFeedViewModel = viewModel(
key = baseUser.pubkeyHex + "UserProfileReportFeedViewModel",
factory = NostrUserProfileReportFeedViewModel.Factory(
baseUser
)
)
ProfileScreen(
baseUser = baseUser,
threadsViewModel,
repliesViewModel,
followsFeedViewModel,
followersFeedViewModel,
appRecommendations,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
UserProfileNewThreadFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileConversationsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileReportsFeedFilter.loadUserProfile(baseUser)
UserProfileBookmarksFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
NostrUserProfileDataSource.loadUserProfile(baseUser)
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -224,16 +253,42 @@ fun ProfileScreen(
}
}
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
RenderSurface(
baseUser,
threadsViewModel,
repliesViewModel,
appRecommendations,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderSurface(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
Column(
modifier = Modifier
@@ -242,58 +297,110 @@ fun ProfileScreen(
columnSize = it
}
) {
Box(
modifier = Modifier
.verticalScroll(scrollState)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// When scrolling vertically, scroll the container first.
return if (available.y < 0 && scrollState.canScrollForward) {
coroutineScope.launch {
scrollState.scrollBy(-available.y)
}
Offset(0f, available.y)
} else {
Offset.Zero
}
}
})
.fillMaxHeight()
) {
Column() {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
modifier = Modifier.onSizeChanged {
tabsSize = it
}
) {
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
pageCount = 8,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
}
) { page ->
CreateAndRenderPages(
page,
baseUser,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
accountViewModel,
nav
)
}
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val tabRowModifier = remember {
Modifier.onSizeChanged {
tabsSize = it
}
}
val pagerModifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
}
Box(
modifier = remember {
Modifier
.verticalScroll(scrollState)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// When scrolling vertically, scroll the container first.
return if (available.y < 0 && scrollState.canScrollForward) {
coroutineScope.launch {
scrollState.scrollBy(-available.y)
}
Offset(0f, available.y)
} else {
Offset.Zero
}
}
})
.fillMaxHeight()
}
) {
RenderScreen(
baseUser,
pagerState,
tabRowModifier,
pagerModifier,
threadsViewModel,
repliesViewModel,
appRecommendations,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav
)
}
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderScreen(
baseUser: User,
pagerState: PagerState,
tabRowModifier: Modifier,
pagerModifier: Modifier,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
appRecommendations: NostrUserAppRecommendationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column() {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
modifier = tabRowModifier
) {
CreateAndRenderTabs(baseUser, pagerState)
}
HorizontalPager(
pageCount = 8,
state = pagerState,
modifier = pagerModifier
) { page ->
CreateAndRenderPages(
page,
baseUser,
threadsViewModel,
repliesViewModel,
followsFeedViewModel,
followersFeedViewModel,
zapFeedViewModel,
bookmarksFeedViewModel,
reportsFeedViewModel,
accountViewModel,
nav
)
}
}
}
@@ -302,20 +409,24 @@ fun ProfileScreen(
private fun CreateAndRenderPages(
page: Int,
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
repliesViewModel: NostrUserProfileConversationsFeedViewModel,
followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel,
followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel,
zapFeedViewModel: NostrUserProfileZapsFeedViewModel,
bookmarksFeedViewModel: NostrUserProfileBookmarksFeedViewModel,
reportsFeedViewModel: NostrUserProfileReportFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (page) {
0 -> TabNotesNewThreads(accountViewModel, nav)
1 -> TabNotesConversations(accountViewModel, nav)
0 -> TabNotesNewThreads(threadsViewModel, accountViewModel, nav)
1 -> TabNotesConversations(repliesViewModel, accountViewModel, nav)
2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav)
3 -> TabFollowers(baseUser, followersFeedViewModel, accountViewModel, nav)
4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav)
5 -> TabBookmarks(baseUser, accountViewModel, nav)
6 -> TabReports(baseUser, accountViewModel, nav)
5 -> TabBookmarks(bookmarksFeedViewModel, accountViewModel, nav)
6 -> TabReports(baseUser, reportsFeedViewModel, accountViewModel, nav)
7 -> TabRelays(baseUser, accountViewModel, nav)
}
}
@@ -368,8 +479,7 @@ private fun ReportsTabHeader(baseUser: User) {
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
UserProfileReportsFeedFilter.user = baseUser
val newSize = UserProfileReportsFeedFilter.feed().size
val newSize = UserProfileReportsFeedFilter(baseUser).feed().size
if (newSize != userReports) {
userReports = newSize
@@ -1126,9 +1236,7 @@ public fun DrawBanner(baseUser: User) {
}
@Composable
fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
fun TabNotesNewThreads(feedViewModel: NostrUserProfileNewThreadsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
@@ -1149,9 +1257,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
}
@Composable
fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
fun TabNotesConversations(feedViewModel: NostrUserProfileConversationsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
@@ -1172,9 +1278,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
}
@Composable
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
fun TabBookmarks(feedViewModel: NostrUserProfileBookmarksFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
@@ -1264,9 +1368,7 @@ private fun WatchZapsAndUpdateFeed(
}
@Composable
fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val feedViewModel: NostrUserProfileReportFeedViewModel = viewModel()
fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -62,6 +63,7 @@ val StdPadding = Modifier.padding(10.dp)
val Size15Modifier = Modifier.size(15.dp)
val Size20Modifier = Modifier.size(20.dp)
val Size22Modifier = Modifier.size(22.dp)
val Size24Modifier = Modifier.size(24.dp)
val Size30Modifier = Modifier.size(30.dp)
val Size55Modifier = Modifier.size(55.dp)
@@ -75,7 +77,18 @@ val WidthAuthorPictureModifier = Modifier.width(55.dp)
val DiviserThickness = 0.25.dp
val ReactionRowHeight = Modifier.height(24.dp).padding(start = 10.dp)
val ReactionRowHeightChat = Modifier.height(25.dp)
val UserNameRowHeight = Modifier.height(22.dp).fillMaxWidth()
val UserNameMaxRowHeight = Modifier.heightIn(max = 22.dp).fillMaxWidth()
val Height4dpModifier = Modifier.height(4.dp)
val AccountPictureModifier = Modifier.width(55.dp).height(55.dp).clip(shape = CircleShape)
val ShowMoreRelaysButtonIconButtonModifier = Modifier.size(24.dp)
val ShowMoreRelaysButtonIconModifier = Modifier.size(15.dp)
val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp)
val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f)
val ModifierWidth3dp = Modifier.width(3.dp)
@@ -121,6 +121,26 @@ val LightImageModifier = Modifier
QuoteBorder
)
val DarkReplyBorderModifier = Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
DarkSubtleBorder,
QuoteBorder
)
val LightReplyBorderModifier = Modifier
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
LightSubtleBorder,
QuoteBorder
)
val MarkDownStyleOnDark = richTextDefaults.copy(
paragraphSpacing = DefaultParagraphSpacing,
headingStyle = DefaultHeadingStyle,
@@ -243,6 +263,9 @@ val Colors.repostProfileBorder: Modifier
val Colors.imageModifier: Modifier
get() = if (isLight) LightImageModifier else DarkImageModifier
val Colors.replyModifier: Modifier
get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier
@Composable
fun AmethystTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>\
</style>
</resources>
+99 -2
View File
@@ -344,9 +344,106 @@
<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>
<string name="connect_via_tor_short">Orbot/Tor setup</string>
<string name="connect_via_tor">Verbinden via Orbot setup</string>
<string name="do_you_really_want_to_disable_tor_title">Verbinding Orbot/Tor verbreken?</string>
<string name="do_you_really_want_to_disable_tor_text">Je gegevens worden onmiddellijk overgezet via het reguliere netwerk</string>
<string name="yes">Ja</string>
<string name="no">Nee</string>
<string name="follow_list_selection">Volgerslijst</string>
<string name="follow_list_kind3follows">Alle gevolgden</string>
<string name="follow_list_kind3follows">Iedereen</string>
<string name="follow_list_global">Globaal</string>
<string name="connect_through_your_orbot_setup_markdown">
## Verbind via Tor met Orbot
\n\n1. Installeer [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Start Orbot
\n3. Controleer in Orbot de Socks-poort. Standaard wordt 9050 gebruikt
\n4. Wijzig indien nodig de poort in Orbot
\n5. Configureer de Socks-poort in dit scherm
\n6. Druk op de knop Activeren om Orbot als proxy te gebruiken
</string>
<string name="orbot_socks_port">Orbot Socks Poort</string>
<string name="invalid_port_number">Ongeldige poort</string>
<string name="use_orbot">Gebruik Orbot</string>
<string name="disconnect_from_your_orbot_setup">Ontkoppel Orbot/Tor</string>
<string name="app_notification_channel_id" translatable="false">StandaardKanaalID</string>
<string name="app_notification_private_message" translatable="false">Nieuwe notificatie ontvangen</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivéberichtenID</string>
<string name="app_notification_dms_channel_name">Privéberichten</string>
<string name="app_notification_dms_channel_description">Geeft een melding wanneer er een privébericht binnenkomt</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
<string name="app_notification_zaps_channel_name">Zaps ontvangen</string>
<string name="app_notification_zaps_channel_description">Geeft een melding als iemand je zapt</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">Van %1$s</string>
<string name="app_notification_zaps_channel_message_for">voor %1$s</string>
<string name="reply_notify">Meld: </string>
<string name="channel_list_join_conversation">Doe mee aan het gesprek</string>
<string name="channel_list_user_or_group_id">Gebruiker of groeps ID</string>
<string name="channel_list_user_or_group_id_demo">npub, nevent of hex</string>
<string name="channel_list_create_channel">Maken</string>
<string name="channel_list_join_channel">Deelnemen</string>
<string name="today">Vandaag</string>
<string name="content_warning">Content waarschuwing</string>
<string name="content_warning_explanation">Dit bericht bevat gevoelige inhoud die sommige mensen beledigend of verontrustend kunnen vinden</string>
<string name="content_warning_hide_all_sensitive_content">Verberg altijd gevoelige inhoud</string>
<string name="content_warning_show_all_sensitive_content">Toon altijd gevoelige inhoud</string>
<string name="content_warning_see_warnings">Toon altijd content waarshuwingen</string>
<string name="recommended_apps">Raadt aan: </string>
<string name="filter_spam_from_strangers">Filter spam van onbekenden</string>
<string name="warn_when_posts_have_reports_from_your_follows">Waarschuw wanneer berichten meldingen hebben van je volgers</string>
<string name="new_reaction_symbol">Nieuwe reactie symbool</string>
<string name="no_reaction_type_setup_long_press_to_change">Geen reactietypes geselecteerd. Lang indrukken om te wijzigen</string>
<string name="zapraiser">Zapraiser</string>
<string name="zapraiser_explainer">Voegt een streefaantal sats toe dat moet worden opgehaald voor deze post. Ondersteunende clients kunnen dit als een voortgangsbalk weergeven om donaties te stimuleren.</string>
<string name="zapraiser_target_amount_in_sats">Streefaantal in sats</string>
<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_ocurred_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>
<string name="contact">Contact</string>
<string name="supports">Ondersteunde NIPs</string>
<string name="admission_fees">Toelatingskosten</string>
<string name="payments_url">Betaallink</string>
<string name="limitations">Beperkingen</string>
<string name="countries">Landen</string>
<string name="languages">Talen</string>
<string name="tags">Tags</string>
<string name="posting_policy">Postbeleid</string>
<string name="message_length">Lengte van bericht</string>
<string name="subscriptions">Abonnementen</string>
<string name="filters">Filters</string>
<string name="subscription_id_length">Lengte abonnement-id</string>
<string name="minimum_prefix">Minimum prefix</string>
<string name="maximum_event_tags">Maximum event tags</string>
<string name="content_length">Content lengte</string>
<string name="minimum_pow">Minimum PoW</string>
<string name="auth">Auth</string>
<string name="payment">Betaling</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>
<string name="live_stream_planned_tag">INGEPLAND</string>
<string name="live_stream_is_offline">Livestream is offline</string>
<string name="live_stream_has_ended">Livestream is geëindigd</string>
</resources>
+11
View File
@@ -456,4 +456,15 @@
<string name="minimum_pow">Minimum PoW</string>
<string name="auth">Auth</string>
<string name="payment">Payment</string>
<string name="cashu">Cashu Token</string>
<string name="cashu_redeem">Redeem</string>
<string name="live_stream_live_tag">LIVE</string>
<string name="live_stream_offline_tag">OFFLINE</string>
<string name="live_stream_ended_tag">ENDED</string>
<string name="live_stream_planned_tag">SCHEDULED</string>
<string name="live_stream_is_offline">Livestream is Offline</string>
<string name="live_stream_has_ended">Livestream Ended</string>
<string name="are_you_sure_you_want_to_log_out">Logging out deletes all your local information. Make sure to have your private keys backed up to avoid losing your account. Do you want to continue?</string>
</resources>
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Amethyst" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:windowLayoutInDisplayCutoutMode" tools:ignore="NewApi">shortEdges</item>
</style>
</resources>