mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 06:44:37 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5ff5bea9 | ||
|
|
1767d4a3a3 | ||
|
|
f2d87b78b1 | ||
|
|
4bf76d1b79 | ||
|
|
17fbf8c01f | ||
|
|
332490376b | ||
|
|
14e670cd9a | ||
|
|
d29e6b4d0b | ||
|
|
3558bab786 | ||
|
|
7c21f077db | ||
|
|
a2a9d04e1e | ||
|
|
fc56f3988e | ||
|
|
95ad33ace2 | ||
|
|
cf850415b8 | ||
|
|
bd77bb7780 | ||
|
|
6a696bfa39 | ||
|
|
d5fc97184c | ||
|
|
e0b9120e17 | ||
|
|
1523a29a4f | ||
|
|
1db6b4793f | ||
|
|
426a495eb4 | ||
|
|
e07b4d49ef | ||
|
|
595cab3630 | ||
|
|
817a9c3e8d | ||
|
|
32927040e3 | ||
|
|
c973d941cc | ||
|
|
db6ebe609c | ||
|
|
32913915ce | ||
|
|
4a8680e582 | ||
|
|
7cf0291b5a | ||
|
|
0406632e9d | ||
|
|
bf2949717c | ||
|
|
bbea28c6df | ||
|
|
1c76b7a35c | ||
|
|
814d05812a | ||
|
|
b4d3cf87c8 | ||
|
|
e5ff92c804 | ||
|
|
c3a31fd313 | ||
|
|
3fd4c176e8 | ||
|
|
cf9395d5e3 | ||
|
|
89ae229ea2 | ||
|
|
f40b6c394c | ||
|
|
f84501e40f | ||
|
|
d261003217 | ||
|
|
fc1ee54548 | ||
|
|
6842d12b39 | ||
|
|
3e69b81d81 | ||
|
|
ea4e3e75d6 | ||
|
|
5db04ef2b0 | ||
|
|
b51186de30 | ||
|
|
8fc0f5a8b8 | ||
|
|
754ac9894b | ||
|
|
69744db53b | ||
|
|
db9a0ebfa1 | ||
|
|
38ebeded1c | ||
|
|
2b8fafc123 | ||
|
|
fc2408b526 | ||
|
|
6723806534 | ||
|
|
93c3a49ecf | ||
|
|
e80c8b37b3 | ||
|
|
66b84c0dea | ||
|
|
a7063042fb | ||
|
|
aa60367c72 | ||
|
|
19a58bb669 | ||
|
|
55aafffb37 | ||
|
|
9b92dfca5e | ||
|
|
3ae75a3333 | ||
|
|
9ebb3fbefc | ||
|
|
96d66646b4 | ||
|
|
f202422ff1 | ||
|
|
f02e18ff9b | ||
|
|
b72b0b028f | ||
|
|
eb9be2108d | ||
|
|
875eb0df78 | ||
|
|
ea8a665bc7 | ||
|
|
142f850eb7 | ||
|
|
9f12fffd0d | ||
|
|
28a1f0d6da | ||
|
|
b1de36f423 | ||
|
|
ff54d581ae | ||
|
|
8fcd05a6bb | ||
|
|
434c1e9af3 | ||
|
|
a62f2e44d0 | ||
|
|
aa0ace5014 | ||
|
|
ee4c54aa27 | ||
|
|
7ff408bfb9 | ||
|
|
c1d127ac1e | ||
|
|
0b2e5de752 | ||
|
|
920ce54fe3 | ||
|
|
c87b246a4e | ||
|
|
b8bc370bc1 | ||
|
|
a8069f7a3e | ||
|
|
ca9b7adfa0 | ||
|
|
3c3b6544cf | ||
|
|
910849b3c7 | ||
|
|
411ba0f137 | ||
|
|
d48a15bfb3 | ||
|
|
489941106b | ||
|
|
a0391fb8a5 | ||
|
|
633be54dd4 | ||
|
|
470281fa87 | ||
|
|
3287196243 | ||
|
|
bb8bb16370 | ||
|
|
0d051117b2 | ||
|
|
d31eca1b42 | ||
|
|
a503a1b218 | ||
|
|
0bf317a818 | ||
|
|
915f7c07e8 | ||
|
|
69a1302404 | ||
|
|
43156e3e11 | ||
|
|
faf215110a | ||
|
|
839e00ae36 | ||
|
|
b829a42d76 | ||
|
|
e1c3191f31 | ||
|
|
28521c1a60 | ||
|
|
9c30547bd7 | ||
|
|
b07fd253c7 | ||
|
|
264d6a40b3 | ||
|
|
20a65a0878 | ||
|
|
6285bfaae7 | ||
|
|
77a1b2ba89 | ||
|
|
3217d5095b | ||
|
|
5d9d7ba16e | ||
|
|
c648f54b51 | ||
|
|
4f8d33d850 | ||
|
|
9f3d6a75e6 | ||
|
|
ad420da6de | ||
|
|
a354fe928b | ||
|
|
cf8747b19f | ||
|
|
2ae54b544f | ||
|
|
bae1b6c6e6 | ||
|
|
ad2eabf386 |
+1
-1
@@ -4,7 +4,7 @@ Effective as of Jan 20, 2023 for the distributed applications in the Play Store
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
|
||||
# Current Features
|
||||
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
|
||||
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
|
||||
- [x] Home Feed
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
@@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Created_at Limits (NIP-22)
|
||||
- [x] Event Deletion (NIP-09)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
- [x] Long-form Content (NIP-23)
|
||||
- [x] Parameterized Replaceable Events (NIP-33)
|
||||
@@ -54,13 +55,18 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [x] Custom Emoji (NIP-30)
|
||||
- [x] Text Note References (NIP-27)
|
||||
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
|
||||
- [x] Push Notifications (Zaps and Messages)
|
||||
- [x] Generic Tags (NIP-12)
|
||||
- [ ] Marketplace (NIP-15)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
- [ ] Workspaces
|
||||
@@ -69,8 +75,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [ ] Relay List Metadata (NIP-65)
|
||||
- [ ] Signing Requests (NIP-46)
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Mnemonic seed phrase (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] OpenTimestamps Attestations (NIP-03)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
+10
-5
@@ -2,6 +2,7 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 151
|
||||
versionName "0.44.0"
|
||||
versionCode 165
|
||||
versionName "0.49.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -85,8 +86,8 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -114,7 +115,7 @@ dependencies {
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -172,6 +173,10 @@ dependencies {
|
||||
// Google services model the translate text
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "768341258853",
|
||||
"project_id": "amethyst-3057a",
|
||||
"storage_bucket": "amethyst-3057a.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CitationTests {
|
||||
val json = """
|
||||
{
|
||||
"content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
|
||||
"created_at": 1683596206,
|
||||
"id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758",
|
||||
"kind": 1,
|
||||
"pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d",
|
||||
"sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3",
|
||||
"",
|
||||
"root"
|
||||
],
|
||||
[
|
||||
"e",
|
||||
"be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc",
|
||||
"",
|
||||
"reply"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://nostr.wine/"
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test
|
||||
fun parseEvent() {
|
||||
val event = Event.fromJson(json, true) as TextNoteEvent
|
||||
|
||||
val expectedCitations = setOf(
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
)
|
||||
|
||||
assertEquals(expectedCitations, event.citedUsers())
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
class PushNotificationUtils {
|
||||
fun init(accounts: List<AccountInfo>) {
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
</queries>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
@@ -70,6 +72,7 @@
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
@@ -55,6 +56,8 @@ private object PrefKeys {
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -209,11 +212,19 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences(currentAccount()).apply {
|
||||
val acc = loadFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(npub: String?): Account? {
|
||||
encryptedPreferences(npub).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
@@ -277,6 +288,9 @@ object LocalPreferences {
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
@@ -295,7 +309,9 @@ object LocalPreferences {
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
latestContactList,
|
||||
proxy,
|
||||
proxyPort
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
@@ -26,7 +27,7 @@ object ServiceManager {
|
||||
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
HttpClient.start(account)
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import java.math.BigDecimal
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -59,7 +60,9 @@ class Account(
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy?,
|
||||
var proxyPort: Int
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
|
||||
@@ -874,23 +877,28 @@ class Account(
|
||||
null
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
try {
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
} catch (e: Exception) {
|
||||
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -936,7 +944,7 @@ class Account(
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
var usersRelayList = userProfile().latestContactList?.relays()?.map {
|
||||
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
@@ -946,7 +954,8 @@ class Account(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes
|
||||
Constants.forcedRelayForSearch.feedTypes,
|
||||
proxy
|
||||
)
|
||||
}
|
||||
|
||||
@@ -955,7 +964,7 @@ class Account(
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
return localRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
@@ -1037,14 +1046,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
init {
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerObservers() {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -1071,6 +1073,15 @@ class Account(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
@@ -11,6 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
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.ByteArrayInputStream
|
||||
import java.io.File
|
||||
@@ -280,6 +280,30 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: PinListEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
@@ -347,7 +371,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?) {
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
@@ -357,7 +381,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event != null) return note
|
||||
|
||||
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
|
||||
|
||||
@@ -374,6 +398,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -736,19 +762,18 @@ object LocalCache {
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
|
||||
if (!file.exists()) {
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val file = File(cachePath, event.id)
|
||||
if (!file.exists()) {
|
||||
val stream = FileOutputStream(file)
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
@@ -911,18 +936,74 @@ object LocalCache {
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
|
||||
fun consume(event: Event, relay: Relay?) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ContactListEvent -> consume(event)
|
||||
is DeletionEvent -> consume(event)
|
||||
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
consume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> consume(event)
|
||||
is LnZapPaymentRequestEvent -> consume(event)
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is ReportEvent -> consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
consume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
class LocalCacheLiveData {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
|
||||
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
}
|
||||
_newEventBundles.emit(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) {
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class NIP30Parser {
|
||||
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
fun buildArray(input: String): List<String> {
|
||||
val matcher = customEmojiPattern.matcher(input)
|
||||
val list = mutableListOf<String>()
|
||||
while (matcher.find()) {
|
||||
list.add(matcher.group())
|
||||
}
|
||||
|
||||
if (list.isEmpty()) {
|
||||
return listOf(input)
|
||||
}
|
||||
|
||||
val regularChars = input.split(customEmojiPattern.toRegex())
|
||||
|
||||
var finalList = mutableListOf<String>()
|
||||
var index = 0
|
||||
for (e in regularChars) {
|
||||
finalList.add(e)
|
||||
if (index < list.size) {
|
||||
finalList.add(list[index])
|
||||
}
|
||||
index++
|
||||
}
|
||||
return finalList
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class Nip05Verifier() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
@@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
@@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
@@ -62,51 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is BookmarkListEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
is ChannelMetadataEvent -> LocalCache.consume(event)
|
||||
is ChannelMuteUserEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is FileHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageEvent -> LocalCache.consume(event, relay)
|
||||
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
|
||||
is HighlightEvent -> LocalCache.consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is PeopleListEvent -> LocalCache.consume(event)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is PollNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +103,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -13,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import androidx.compose.ui.text.capitalize
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
@@ -56,7 +58,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
@@ -72,7 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
|
||||
+21
-11
@@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -80,7 +80,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
HighlightEvent.kind,
|
||||
AudioTrackEvent.kind,
|
||||
PinListEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -119,7 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind
|
||||
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
@@ -152,22 +154,30 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
if (!eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
if (eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
if (!addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
if (addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
if (!usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
if (usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
@@ -29,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
@@ -82,7 +81,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
|
||||
+3
-3
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class LightningAddressResolver() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class AudioTrackEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
|
||||
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
|
||||
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
|
||||
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
|
||||
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
|
||||
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31337
|
||||
|
||||
private const val TYPE = "c"
|
||||
private const val PRICE = "price"
|
||||
private const val COVER = "cover"
|
||||
private const val SUBJECT = "subject"
|
||||
private const val MEDIA = "media"
|
||||
|
||||
fun create(
|
||||
type: String,
|
||||
media: String,
|
||||
price: String? = null,
|
||||
cover: String? = null,
|
||||
subject: String? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): AudioTrackEvent {
|
||||
val tags = listOfNotNull(
|
||||
listOf(MEDIA, media),
|
||||
listOf(TYPE, type),
|
||||
price?.let { listOf(PRICE, it) },
|
||||
cover?.let { listOf(COVER, it) },
|
||||
subject?.let { listOf(SUBJECT, it) }
|
||||
)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Participant(val key: String, val role: String?)
|
||||
@@ -9,9 +9,9 @@ class BadgeDefinitionEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
|
||||
|
||||
@@ -9,7 +9,7 @@ class BadgeProfilesEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
@@ -18,8 +18,8 @@ class BadgeProfilesEvent(
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 30008
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
@@ -110,11 +111,12 @@ open class Event(
|
||||
}
|
||||
|
||||
override fun hasValidSignature(): Boolean {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
return false
|
||||
return try {
|
||||
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Event", "Fail checking if event $id has a valid signature", e)
|
||||
false
|
||||
}
|
||||
|
||||
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
}
|
||||
|
||||
private fun generateId(): String {
|
||||
@@ -218,6 +220,7 @@ open class Event(
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -241,6 +244,7 @@ open class Event(
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -280,3 +284,8 @@ open class Event(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AddressableEvent {
|
||||
fun dTag(): String
|
||||
fun address(): ATag
|
||||
}
|
||||
|
||||
@@ -14,21 +14,23 @@ abstract class GeneralListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
if (content.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class LnZapRequestEvent(
|
||||
listOf("p", originalNote.pubKey()),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
|
||||
@@ -12,9 +12,9 @@ class LongTextNoteEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
|
||||
@@ -15,9 +15,9 @@ class MuteListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class PinListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 33888
|
||||
|
||||
fun create(
|
||||
pins: List<String>,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PinListEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
pins.forEach {
|
||||
tags.add(listOf("pin", it))
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class ReactionEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
|
||||
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
|
||||
if (originalNote is LongTextNoteEvent) {
|
||||
if (originalNote is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class ReportEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
|
||||
|
||||
if (reportedPost is LongTextNoteEvent) {
|
||||
if (reportedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class RepostEvent(
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
|
||||
|
||||
if (boostedPost is LongTextNoteEvent) {
|
||||
if (boostedPost is AddressableEvent) {
|
||||
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ object Nip19 {
|
||||
USER, NOTE, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Long? = null,
|
||||
val kind: Int? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
// adds to database
|
||||
LocalCache.consume(event, null)
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
it.pubkeyHex in followingKeySet || acc.userProfile()
|
||||
.hasSentMessagesTo(it)
|
||||
) && !acc.isHidden(it)
|
||||
}.toSet()
|
||||
|
||||
if (note.author in messagingWith) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
|
||||
object NotificationUtils {
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
|
||||
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel = NotificationChannel(
|
||||
applicationContext.getString(R.string.app_notification_dms_channel_id),
|
||||
applicationContext.getString(R.string.app_notification_dms_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = applicationContext.getString(R.string.app_notification_dms_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(dmChannel!!)
|
||||
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel = NotificationChannel(
|
||||
applicationContext.getString(R.string.app_notification_zaps_channel_id),
|
||||
applicationContext.getString(R.string.app_notification_zaps_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = applicationContext.getString(R.string.app_notification_zaps_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(zapChannel!!)
|
||||
|
||||
return zapChannel!!
|
||||
}
|
||||
|
||||
fun NotificationManager.sendZapNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
val request = ImageRequest.Builder(applicationContext)
|
||||
.data(pictureUrl)
|
||||
.build()
|
||||
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val imageResult = imageLoader.executeBlocking(request)
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
} else {
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||
data = Uri.parse(uri)
|
||||
}
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
id.hashCode(),
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
// Build the notification
|
||||
val builderPublic = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
channelId
|
||||
)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
// Build the notification
|
||||
val builder = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
channelId
|
||||
)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notify(id.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all notifications.
|
||||
*/
|
||||
fun NotificationManager.cancelNotifications() {
|
||||
cancelAll()
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class RegisterAccounts(
|
||||
private val accounts: List<AccountInfo>
|
||||
) {
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null) {
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
try {
|
||||
val jsonObject = """{
|
||||
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
|
||||
}
|
||||
"""
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val body = jsonObject.toRequestBody(mediaType)
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url("https://push.amethyst.social/register")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
client.newCall(request).execute()
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("FirebaseMsgService", "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun go(notificationToken: String) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -91,7 +92,7 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
@@ -10,7 +11,7 @@ object Constants {
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
@@ -23,9 +25,16 @@ class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true,
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet()
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
proxy: Proxy?
|
||||
) {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
@@ -20,7 +20,12 @@ import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
@@ -39,15 +44,30 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val nip19 = Nip19.uriToRoute(intent?.data?.toString())
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
val uri = intent?.data?.toString()
|
||||
|
||||
val startingPage = if (uri.equals("nostr:Notifications", true)) {
|
||||
Route.Notification.route.replace("{scrollToTop}", "true")
|
||||
} else {
|
||||
val nip19 = Nip19.uriToRoute(uri)
|
||||
when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> {
|
||||
if (nip19.kind == PrivateDmEvent.kind) {
|
||||
"Room/${nip19.author}"
|
||||
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
}
|
||||
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
} ?: try {
|
||||
intent?.data?.toString()?.let {
|
||||
uri?.let {
|
||||
Nip47.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
@@ -100,6 +120,8 @@ class MainActivity : FragmentActivity() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
@@ -32,7 +33,7 @@ object ImageSaver {
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
@@ -70,7 +71,7 @@ object ImageUploader {
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
searchBarViewModel.clean()
|
||||
onClose()
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
searchBarViewModel.clean()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_join_conversation),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
RenderSeach(searchBarViewModel, account, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun RenderSeach(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
val searchTextChanges = remember {
|
||||
Channel<String>(Channel.CONFLATED)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
// Wait for text changes to stop for 300 ms before firing off search.
|
||||
withContext(Dispatchers.IO) {
|
||||
searchTextChanges.receiveAsFlow()
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.collectLatest {
|
||||
if (it.length >= 2) {
|
||||
onlineSearch.search(it.trim())
|
||||
}
|
||||
|
||||
searchBarViewModel.invalidateData()
|
||||
|
||||
// makes sure to show the top of the search
|
||||
scope.launch(Dispatchers.Main) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Join Start")
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Join Stop")
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
|
||||
value = searchBarViewModel.searchValue,
|
||||
onValueChange = {
|
||||
searchBarViewModel.updateSearchValue(it)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
searchTextChanges.trySend(it)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
.defaultMinSize(minHeight = 20.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_user_or_group_id_demo),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchBarViewModel.isTrailingIconVisible) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
searchBarViewModel.clean()
|
||||
onlineSearch.clear()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (searchBarViewModel.searchValue.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResults.value,
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(
|
||||
item,
|
||||
account = account,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResultsChannels.value,
|
||||
key = { _, item -> "c" + item.idHex }
|
||||
) { _, item ->
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
onClick = { navController.navigate("Channel/${item.idHex}") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserComposeForChat(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(baseUser)
|
||||
}
|
||||
|
||||
val baseUserState by baseUser.live().metadata.observeAsState()
|
||||
val user = baseUserState?.user ?: return
|
||||
|
||||
Text(
|
||||
user.info?.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -47,6 +49,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -58,9 +61,8 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
@@ -81,6 +83,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
@@ -145,10 +148,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "✖ ") {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
Notifying(postViewModel.mentions) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -219,6 +220,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
postViewModel.imageUploadingError.emit(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -345,6 +351,48 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
val mentions = baseMentions?.toSet()
|
||||
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
|
||||
if (!mentions.isNullOrEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.reply_notify),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
Button(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
),
|
||||
onClick = {
|
||||
onClick(myUser)
|
||||
}
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = "✖ ${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPollButton(
|
||||
isPollActive: Boolean,
|
||||
@@ -601,7 +649,8 @@ fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
@@ -709,7 +758,12 @@ fun ImageVideoDescription(
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
onError("Unable to load file")
|
||||
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,8 +198,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(it: User) {
|
||||
mentions = mentions?.minus(it)
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
mentions = mentions?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
|
||||
+38
-37
@@ -6,69 +6,78 @@ import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollView
|
||||
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
|
||||
@Composable
|
||||
fun FabColumn(account: Account) {
|
||||
fun ChannelFabColumn(account: Account, navController: NavController) {
|
||||
var isOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPoll by remember {
|
||||
|
||||
var wantsToJoinChannelOrUser by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPost by remember {
|
||||
|
||||
var wantsToCreateChannel by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToCreateChannel) {
|
||||
NewChannelView({ wantsToCreateChannel = false }, account = account)
|
||||
}
|
||||
|
||||
if (wantsToJoinChannelOrUser) {
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (isOpen) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPoll = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_join_channel),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPost = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
onClick = { wantsToCreateChannel = true; isOpen = false },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_create_channel),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { isOpen = !isOpen },
|
||||
modifier = Modifier.size(55.dp),
|
||||
@@ -77,19 +86,11 @@ fun FabColumn(account: Account) {
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = stringResource(R.string.new_channel),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
|
||||
}
|
||||
|
||||
if (wantsToPoll) {
|
||||
NewPollView({ wantsToPoll = false }, account = account)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
@@ -54,23 +54,26 @@ class BundledInsert<T>(
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
|
||||
atomicSet.updateAndGet() {
|
||||
it + newObject
|
||||
}
|
||||
private var queue = LinkedBlockingQueue<T>()
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
|
||||
queue.put(newObject)
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
val mySet = mutableSetOf<T>()
|
||||
queue.drainTo(mySet)
|
||||
onUpdate(mySet)
|
||||
|
||||
delay(delay)
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
|
||||
val mySet2 = mutableSetOf<T>()
|
||||
queue.drainTo(mySet2)
|
||||
onUpdate(mySet2)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
onlyOneInBlock.set(false)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
@@ -11,12 +19,30 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -63,31 +89,31 @@ private fun DisplayEvent(
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Event/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Event/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -114,36 +140,36 @@ private fun DisplayNote(
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -173,10 +199,10 @@ private fun DisplayAddress(
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,14 +228,19 @@ private fun DisplayUser(
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val route = remember { "User/${it.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
CreateClickableText(
|
||||
user.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"User/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
@@ -220,20 +251,281 @@ private fun DisplayUser(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
|
||||
fun CreateClickableText(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
|
||||
) {
|
||||
append("@$clickablePart")
|
||||
append(clickablePart)
|
||||
}
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
|
||||
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()
|
||||
) {
|
||||
append("$suffix ")
|
||||
append(suffix)
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate(route) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
tags: List<List<String>>?,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val emojis = remember {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: Map<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(text, emojis)
|
||||
}
|
||||
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
tags: List<List<String>>?,
|
||||
style: TextStyle,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
ClickableText(
|
||||
AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
|
||||
onClick(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
tags: List<List<String>>?,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
navController: NavController
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, navController)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
navController.navigate(route)
|
||||
}
|
||||
|
||||
val myList2 = remember {
|
||||
assembleAnnotatedList(suffix, emojis)
|
||||
}
|
||||
|
||||
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
}
|
||||
}
|
||||
|
||||
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
|
||||
return NIP30Parser().buildArray(text).map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
ImageUrlType(url)
|
||||
} else {
|
||||
TextType(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open class Renderable()
|
||||
class TextType(val text: String) : Renderable()
|
||||
class ImageUrlType(val url: String) : Renderable()
|
||||
|
||||
@Composable
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it.first to it.second }
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator = Modifier.pointerInput(onClick) {
|
||||
detectTapGestures { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = annotatedText,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InLineIconRenderer(
|
||||
wordsInOrder: List<Renderable>,
|
||||
style: SpanStyle,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.associate { it.first to it.second }
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedText,
|
||||
inlineContent = inlineContent,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
+9
-4
@@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -50,10 +51,14 @@ fun ExpandableRichTextViewer(
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
}
|
||||
|
||||
val text = if (showFullText) {
|
||||
content
|
||||
} else {
|
||||
content.take(whereToCut)
|
||||
val text by remember(content) {
|
||||
derivedStateOf {
|
||||
if (showFullText) {
|
||||
content
|
||||
} else {
|
||||
content.take(whereToCut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
|
||||
@@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
@@ -80,7 +81,8 @@ fun isMarkdown(content: String): Boolean {
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
content.contains("```") ||
|
||||
content.contains("](")
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -106,9 +108,10 @@ fun RichTextViewer(
|
||||
|
||||
class RichTextViewerState(
|
||||
val content: String,
|
||||
val urlSet: LinkedHashSet<String>,
|
||||
val urlSet: Set<String>,
|
||||
val imagesForPager: Map<String, ZoomableUrlContent>,
|
||||
val imageList: List<ZoomableUrlContent>
|
||||
val imageList: List<ZoomableUrlContent>,
|
||||
val customEmoji: Map<String, String>
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -121,11 +124,13 @@ private fun RenderRegular(
|
||||
navController: NavController
|
||||
) {
|
||||
var processedState by remember {
|
||||
mutableStateOf<RichTextViewerState?>(null)
|
||||
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
@@ -140,7 +145,11 @@ private fun RenderRegular(
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +171,8 @@ private fun RenderRegular(
|
||||
ZoomableContentView(img, state.imageList)
|
||||
} else if (state.urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (state.customEmoji.any { word.contains(it.key) }) {
|
||||
RenderCustomEmoji(word, state.customEmoji)
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
@@ -230,6 +241,8 @@ private fun RenderRegular(
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (state.customEmoji.any { word.contains(it.key) }) {
|
||||
RenderCustomEmoji(word, state.customEmoji)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
@@ -288,6 +301,14 @@ private fun RenderRegular(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
|
||||
CreateTextWithEmoji(
|
||||
text = "$word ",
|
||||
emojis = customEmoji
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
@@ -393,7 +414,7 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
return LocalCache.users[nip19.hex]?.bestDisplayName()
|
||||
return LocalCache.users[nip19.hex]?.toBestDisplayName()
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
@@ -595,8 +616,10 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
@@ -625,8 +648,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
}
|
||||
|
||||
baseUserPair?.let {
|
||||
ClickableUserTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
val innerUserState by it.first.live().metadata.observeAsState()
|
||||
val displayName = remember(innerUserState) {
|
||||
innerUserState?.user?.toBestDisplayName() ?: ""
|
||||
}
|
||||
val route = remember(innerUserState) {
|
||||
"User/${it.first.pubkeyHex}"
|
||||
}
|
||||
val userTags = remember(innerUserState) {
|
||||
innerUserState?.user?.info?.latestMetadata?.tags
|
||||
}
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = displayName,
|
||||
suffix = "${it.second} ",
|
||||
tags = userTags,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
baseNotePair?.let {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
@@ -15,6 +16,7 @@ import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.size.Size
|
||||
import java.util.Date
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
@@ -30,27 +32,34 @@ fun RobohashAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
with(LocalDensity.current) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(
|
||||
LocalContext.current,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
val context = LocalContext.current
|
||||
val size = with(LocalDensity.current) {
|
||||
robotSize.roundToPx()
|
||||
}
|
||||
|
||||
val imageRequest = remember(size, robot) {
|
||||
Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(size, size)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
var imageErrors = setOf<String>()
|
||||
var imageErrors = mapOf<String, Long>()
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
@@ -65,7 +74,9 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (imageErrors.contains(model)) {
|
||||
val errorCache = remember(imageErrors) { imageErrors[model] }
|
||||
|
||||
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = robotSize,
|
||||
@@ -102,7 +113,7 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
onError = {
|
||||
imageErrors = imageErrors + model
|
||||
imageErrors = imageErrors + Pair(model, Date().time / 1000)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -120,7 +131,7 @@ fun RobohashAsyncImageProxy(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val proxy = model.proxyUrl()
|
||||
val proxy = remember(model) { model.proxyUrl() }
|
||||
if (proxy == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -41,13 +42,15 @@ fun UrlPreviewCard(
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
val validatedUrl = URL(previewInfo.url)
|
||||
val validatedUrl = remember { URL(previewInfo.url) }
|
||||
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||
URL(validatedUrl, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
val imageUrl = remember {
|
||||
if (previewInfo.image.startsWith("/")) {
|
||||
URL(validatedUrl, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -55,6 +60,8 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
public var muted = mutableStateOf(true)
|
||||
@@ -62,20 +69,55 @@ public var muted = mutableStateOf(true)
|
||||
@Composable
|
||||
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
if (localFile != null) {
|
||||
VideoView(localFile.toUri(), description, onDialog)
|
||||
VideoView(localFile.toUri(), description, null, onDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
VideoView(Uri.parse(videoUri), description, onDialog)
|
||||
VideoView(Uri.parse(videoUri), description, null, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val myCover = context.imageLoader.execute(request).drawable
|
||||
if (myCover != null) {
|
||||
loadingFinished = Pair(true, myCover)
|
||||
} else {
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog)
|
||||
} else {
|
||||
VideoView(videoUri, description, onDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
println("loading audio with artwork $thumb")
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
@@ -89,7 +131,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
@@ -116,9 +158,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible) {
|
||||
if (visible && !exoPlayer.isPlaying) {
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
} else if (!visible && exoPlayer.isPlaying) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
},
|
||||
@@ -130,6 +172,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumb?.let { defaultArtwork = thumb }
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
@@ -146,7 +189,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
muted.value = !muted.value
|
||||
}
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -175,7 +217,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
|
||||
}
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
isVisible = coordinates.isCompletelyVisible(view)
|
||||
val newIsVisible = coordinates.isCompletelyVisible(view)
|
||||
if (isVisible != newIsVisible) {
|
||||
isVisible = newIsVisible
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -209,17 +211,21 @@ private fun LocalImageView(
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
}
|
||||
|
||||
val contentScale = remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
AsyncImage(
|
||||
@@ -271,10 +277,6 @@ private fun UrlImageView(
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -284,12 +286,20 @@ private fun UrlImageView(
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
}
|
||||
|
||||
val contentScale = remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
|
||||
@@ -27,7 +27,7 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
|
||||
(it.event is BaseTextNoteEvent || it.event is AudioTrackEvent) && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
val channel = it.channelHex()
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||
|
||||
@@ -48,7 +48,15 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
val event = note.event
|
||||
|
||||
if (event is BaseTextNoteEvent) {
|
||||
return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
|
||||
val isAuthoredPostCited = event.findCitations().any {
|
||||
LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author
|
||||
}
|
||||
|
||||
return isAuthoredPostCited ||
|
||||
(
|
||||
event.citedUsers().contains(author.pubkeyHex) ||
|
||||
note.replyTo?.any { it.author === author } == true
|
||||
)
|
||||
}
|
||||
|
||||
if (event is ReactionEvent) {
|
||||
|
||||
+4
-1
@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
@@ -15,7 +17,8 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values.filter { it.author == user }
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent) }
|
||||
|
||||
return user?.notes
|
||||
?.plus(longFormNotes)
|
||||
|
||||
+5
-1
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
@@ -149,7 +150,10 @@ fun AccountSwitchBottomSheet(
|
||||
val npubShortHex = acc.npub.toShortenHex()
|
||||
|
||||
user.bestDisplayName()?.let {
|
||||
Text(it)
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = user.info?.latestMetadata?.tags
|
||||
)
|
||||
}
|
||||
|
||||
Text(npubShortHex)
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
@@ -136,9 +135,10 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = route.icon),
|
||||
@@ -150,15 +150,10 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
val notifState = NotificationCache.live.observeAsState()
|
||||
val notif = notifState.value ?: return
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -166,9 +161,11 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -40,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
@@ -71,43 +72,45 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)?.substringBefore("?")) {
|
||||
// Route.Profile.route -> TopBarWithBackButton(navController)
|
||||
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
|
||||
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel)
|
||||
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultStoriesFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultNotificationFollowList(listName)
|
||||
}
|
||||
}
|
||||
@@ -237,30 +240,19 @@ private fun LoggedInUserPictureDrawer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
var followLists by remember { mutableStateOf(defaultOptions) }
|
||||
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
|
||||
val followLists = remember(followListsModel.followLists) {
|
||||
(defaultOptions + followListsModel.followLists)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
val followNames = remember(followLists) {
|
||||
derivedStateOf {
|
||||
followLists.map { it.second }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +265,62 @@ fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange:
|
||||
)
|
||||
}
|
||||
|
||||
class FollowListViewModel : ViewModel() {
|
||||
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
var account: Account? = null
|
||||
|
||||
fun load(account: Account?) {
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshFollows()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshFollows() {
|
||||
val myAccount = account ?: return
|
||||
|
||||
val newFollowLists = LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (followLists != newFollowLists) {
|
||||
followLists = newFollowLists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
newNotes.forEach {
|
||||
if (it.event is PeopleListEvent) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -14,7 +16,10 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
@@ -24,7 +29,9 @@ import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,13 +52,18 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@@ -100,7 +112,12 @@ fun DrawerContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: ScaffoldState,
|
||||
navController: NavController
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val accountUserState by baseAccountUser.live().metadata.observeAsState()
|
||||
@@ -152,8 +169,9 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
})
|
||||
)
|
||||
if (accountUser.bestDisplayName() != null) {
|
||||
Text(
|
||||
accountUser.bestDisplayName() ?: "",
|
||||
CreateTextWithEmoji(
|
||||
text = accountUser.bestDisplayName() ?: "",
|
||||
tags = accountUser.info?.latestMetadata?.tags,
|
||||
modifier = Modifier
|
||||
.padding(top = 7.dp)
|
||||
.clickable(onClick = {
|
||||
@@ -169,19 +187,22 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
)
|
||||
}
|
||||
if (accountUser.bestUsername() != null) {
|
||||
Text(
|
||||
" @${accountUser.bestUsername()}",
|
||||
CreateTextWithEmoji(
|
||||
text = " @${accountUser.bestUsername()}",
|
||||
tags = accountUser.info?.latestMetadata?.tags,
|
||||
color = Color.LightGray,
|
||||
modifier = Modifier
|
||||
.padding(top = 15.dp)
|
||||
.clickable(onClick = {
|
||||
accountUser.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
.clickable(
|
||||
onClick = {
|
||||
accountUser.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -197,11 +218,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
})
|
||||
) {
|
||||
Row() {
|
||||
Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${accountUserFollows.cachedFollowCount() ?: "--"}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.following))
|
||||
}
|
||||
Row(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${accountUserFollows.cachedFollowerCount() ?: "--"}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.followers))
|
||||
}
|
||||
}
|
||||
@@ -221,8 +248,12 @@ fun ListContent(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(account.proxy != null) }
|
||||
var disconnectTorDialog by remember { mutableStateOf(false) }
|
||||
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
|
||||
|
||||
Column(modifier = modifier.fillMaxHeight()) {
|
||||
Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState())) {
|
||||
if (accountUser != null) {
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.profile),
|
||||
@@ -256,7 +287,36 @@ fun ListContent(
|
||||
title = stringResource(R.string.backup_keys),
|
||||
icon = R.drawable.ic_key,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onClick = { backupDialogOpen = true }
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
backupDialogOpen = true
|
||||
}
|
||||
)
|
||||
|
||||
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
|
||||
|
||||
IconRow(
|
||||
title = textTorProxy,
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
conectOrbotDialogOpen = true
|
||||
},
|
||||
onClick = {
|
||||
if (checked) {
|
||||
disconnectTorDialog = true
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
conectOrbotDialogOpen = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
@@ -272,6 +332,65 @@ fun ListContent(
|
||||
if (backupDialogOpen) {
|
||||
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
|
||||
}
|
||||
|
||||
if (conectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
onClose = { conectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
conectOrbotDialogOpen = false
|
||||
disconnectTorDialog = false
|
||||
checked = true
|
||||
enableTor(account, true, proxyPort)
|
||||
},
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
|
||||
if (disconnectTorDialog) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text))
|
||||
},
|
||||
onDismissRequest = {
|
||||
disconnectTorDialog = false
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
checked = false
|
||||
enableTor(account, false, proxyPort)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.yes))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.no))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableTor(
|
||||
account: Account,
|
||||
checked: Boolean,
|
||||
portNumber: MutableState<String>
|
||||
) {
|
||||
account.proxyPort = portNumber.value.toInt()
|
||||
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
LocalPreferences.saveToEncryptedStorage(account)
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -295,12 +414,16 @@ fun NavigationRow(
|
||||
})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit) {
|
||||
fun IconRow(title: String, icon: Int, tint: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -58,6 +58,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
@@ -218,14 +220,14 @@ fun ChatroomMessageCompose(
|
||||
})
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${author.toBestDisplayName()}",
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = " ${author.toBestDisplayName()}",
|
||||
suffix = "",
|
||||
tags = author.info?.latestMetadata?.tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
overrideColor = MaterialTheme.colors.onBackground,
|
||||
route = "User/${author.pubkeyHex}",
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -251,33 +253,39 @@ fun ChatroomMessageCompose(
|
||||
val event = note.event
|
||||
if (event is ChannelCreateEvent) {
|
||||
val channelInfo = event.channelInfo()
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.created)} " + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
val text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.created)} " + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
|
||||
CreateTextWithEmoji(
|
||||
text = text,
|
||||
tags = note.author?.info?.latestMetadata?.tags
|
||||
)
|
||||
} else if (event is ChannelMetadataEvent) {
|
||||
val channelInfo = event.channelInfo()
|
||||
Text(
|
||||
text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + "$', {stringResource(R.string.description_to)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
val text = note.author?.toBestDisplayName()
|
||||
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.description_to)} '" + (
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
|
||||
CreateTextWithEmoji(
|
||||
text = text,
|
||||
tags = note.author?.info?.latestMetadata?.tags
|
||||
)
|
||||
} else {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.foundation.layout.width
|
||||
@@ -34,18 +35,18 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by messageSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
BlankNote(Modifier)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
var isNew by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -66,44 +67,40 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = if (!isInnerNote) 12.dp else 0.dp,
|
||||
end = if (!isInnerNote) 12.dp else 0.dp,
|
||||
top = 10.dp
|
||||
val columnModifier = remember(isNew) {
|
||||
Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
.fillMaxWidth()
|
||||
}
|
||||
|
||||
Column(columnModifier) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
null,
|
||||
modifier = remember { Modifier.size(16.dp).align(Alignment.TopEnd) },
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
// Draws the like picture outside the boosted card.
|
||||
if (!isInnerNote) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(top = 5.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
NoteCompose(
|
||||
baseNote = messageSetCard.note,
|
||||
routeForLastRead = null,
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
@@ -52,19 +54,19 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by multiSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account }
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
if (note == null || account == null) {
|
||||
BlankNote(Modifier, false)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
var isNew by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -84,112 +86,157 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
val columnModifier = remember(isNew) {
|
||||
Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (multiSetCard.zapEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
// Draws the like picture outside the boosted card.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
}
|
||||
|
||||
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
val zapEvents = remember { multiSetCard.zapEvents }
|
||||
val boostEvents = remember { multiSetCard.boostEvents }
|
||||
val likeEvents = remember { multiSetCard.likeEvents }
|
||||
|
||||
if (multiSetCard.boostEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
Column(modifier = columnModifier) {
|
||||
if (zapEvents.isNotEmpty()) {
|
||||
RenderZapGallery(zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
if (boostEvents.isNotEmpty()) {
|
||||
RenderBoostGallery(boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
if (multiSetCard.likeEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
if (likeEvents.isNotEmpty()) {
|
||||
RenderLikeGallery(likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
NoteCompose(
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
NoteCompose(
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderLikeGallery(
|
||||
likeEvents: List<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderZapGallery(
|
||||
zapEvents: Map<Note, Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGalleryZaps(zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBoostGallery(
|
||||
boostEvents: List<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 4.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: Map<Note, Note>,
|
||||
@@ -197,13 +244,16 @@ fun AuthorGalleryZaps(
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
val accountState = account.userProfile().live().follows.observeAsState()
|
||||
|
||||
val listToRender = remember {
|
||||
authorNotes.keys.take(50)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
|
||||
listToRender.forEach {
|
||||
AuthorPictureAndComment(it, navController, accountState, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +263,7 @@ fun AuthorGalleryZaps(
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountUser: State<UserState?>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = zapRequest.author ?: return
|
||||
@@ -226,8 +276,8 @@ private fun AuthorPictureAndComment(
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(author, decryptedContent.content)
|
||||
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(newAuthor, decryptedContent.content)
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank()) {
|
||||
content = Pair(author, zapRequest.event?.content())
|
||||
@@ -245,19 +295,25 @@ private fun AuthorPictureAndComment(
|
||||
author: User,
|
||||
comment: String?,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountUser: State<UserState?>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val modifier = if (!comment.isNullOrBlank()) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
val modifier = remember(comment) {
|
||||
if (!comment.isNullOrBlank()) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
}
|
||||
} else {
|
||||
Modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
},
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FastNoteAuthorPicture(
|
||||
@@ -288,20 +344,22 @@ fun AuthorGallery(
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
val accountState = account.userProfile().live().follows.observeAsState()
|
||||
val listToRender = remember {
|
||||
Pair(
|
||||
authorNotes.take(50).mapNotNull { it.author },
|
||||
authorNotes.size
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.take(50).forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
listToRender.first.forEach { author ->
|
||||
AuthorPictureAndComment(author, null, navController, accountState, accountViewModel)
|
||||
}
|
||||
|
||||
if (authorNotes.size > 50) {
|
||||
Text(" and ${authorNotes.size - 50} others")
|
||||
if (listToRender.second > 50) {
|
||||
Text(" and ${listToRender.second - 50} others")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,18 +368,26 @@ fun AuthorGallery(
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
author: User,
|
||||
userAccount: User,
|
||||
userAccount: State<UserState?>,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val profilePicture = remember(userState) {
|
||||
userState?.user?.profilePicture()
|
||||
}
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||
val authorPubKey = remember {
|
||||
author.pubkeyHex
|
||||
}
|
||||
|
||||
val showFollowingMark = remember(userAccount.value) {
|
||||
userAccount.value?.user?.isFollowingCached(author) == true || (author === userAccount.value?.user)
|
||||
}
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
userHex = authorPubKey,
|
||||
userPicture = profilePicture,
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = pictureModifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,9 @@ import androidx.core.graphics.ColorUtils
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
@@ -81,11 +84,19 @@ val externalLinkForNote = { note: Note ->
|
||||
if (note is AddressableNote) {
|
||||
if (note.event?.getReward() != null) {
|
||||
"https://nostrbounties.com/b/${note.address().toNAddr()}"
|
||||
} else if (note.event is PeopleListEvent) {
|
||||
"https://listr.lol/a/${note.address()?.toNAddr()}"
|
||||
} else if (note.event is AudioTrackEvent) {
|
||||
"https://zapstr.live/?track=${note.address()?.toNAddr()}"
|
||||
} else {
|
||||
"https://habla.news/a/${note.address().toNAddr()}"
|
||||
"https://habla.news/a/${note.address()?.toNAddr()}"
|
||||
}
|
||||
} else {
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
if (note.event is FileHeaderEvent) {
|
||||
"https://filestr.vercel.app/e/${note.toNEvent()}"
|
||||
} else {
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +139,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
|
||||
if (popupExpanded) {
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)
|
||||
|
||||
Popup(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
@@ -354,9 +365,7 @@ private fun QuickActionAlertDialog(
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onClickDontShowAgain) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -49,19 +49,45 @@ fun PollNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.load(account, zappedNote)
|
||||
val pollViewModel: PollNoteViewModel = viewModel()
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
LaunchedEffect(key1 = baseNote) {
|
||||
pollViewModel.load(account, baseNote)
|
||||
}
|
||||
|
||||
PollNote(
|
||||
baseNote = baseNote,
|
||||
pollViewModel = pollViewModel,
|
||||
canPreview = canPreview,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollNote(
|
||||
baseNote: Note,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
pollViewModel.refreshTallies()
|
||||
}
|
||||
}
|
||||
|
||||
pollViewModel.tallies.forEach { poll_op ->
|
||||
OptionNote(
|
||||
poll_op.key,
|
||||
poll_op.value,
|
||||
poll_op,
|
||||
pollViewModel,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
@@ -74,8 +100,7 @@ fun PollNote(
|
||||
|
||||
@Composable
|
||||
private fun OptionNote(
|
||||
optionNumber: Int,
|
||||
optionText: String,
|
||||
poolOption: PollOption,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -88,32 +113,17 @@ private fun OptionNote(
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (!pollViewModel.canZap()) {
|
||||
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
|
||||
|
||||
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val myTally = pollViewModel.optionVoteTally(optionNumber)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
myTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
|
||||
if (myTally > optionTally.first || color != optionTally.second) {
|
||||
optionTally = Pair(myTally, color)
|
||||
}
|
||||
}
|
||||
val color = if (poolOption.consensusThreadhold) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
}
|
||||
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -121,14 +131,14 @@ private fun OptionNote(
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
optionTally.second,
|
||||
color,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = optionTally.second,
|
||||
progress = optionTally.first.toFloat()
|
||||
color = color,
|
||||
progress = poolOption.tally.toFloat()
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -141,7 +151,7 @@ private fun OptionNote(
|
||||
.width(40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
|
||||
text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
@@ -152,7 +162,7 @@ private fun OptionNote(
|
||||
.padding(15.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
@@ -170,9 +180,9 @@ private fun OptionNote(
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
@@ -186,7 +196,7 @@ private fun OptionNote(
|
||||
)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
@@ -205,9 +215,9 @@ private fun OptionNote(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZapVote(
|
||||
baseNote: Note,
|
||||
poolOption: PollOption,
|
||||
accountViewModel: AccountViewModel,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
pollOption: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nonClickablePrepend: @Composable () -> Unit,
|
||||
clickablePrepend: @Composable () -> Unit
|
||||
@@ -264,7 +274,7 @@ fun ZapVote(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
|
||||
// only allow one vote per option when min==max, i.e. atomic vote amount specified
|
||||
scope.launch {
|
||||
Toast
|
||||
@@ -281,7 +291,7 @@ fun ZapVote(
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
account.zapAmountChoices.first() * 1000,
|
||||
pollOption,
|
||||
poolOption.option,
|
||||
"",
|
||||
context,
|
||||
onError = {
|
||||
@@ -311,7 +321,7 @@ fun ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
pollOption,
|
||||
poolOption.option,
|
||||
onDismiss = {
|
||||
wantsToZap = false
|
||||
zappingProgress = 0f
|
||||
@@ -335,17 +345,7 @@ fun ZapVote(
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!optionWasZappedByLoggedInUser) {
|
||||
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionWasZappedByLoggedInUser) {
|
||||
if (poolOption.zappedByLoggedIn) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -372,20 +372,10 @@ fun ZapVote(
|
||||
}
|
||||
}
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
|
||||
if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) {
|
||||
Text(
|
||||
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
|
||||
showAmount(poolOption.zappedValue),
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = modifier
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -8,19 +12,30 @@ import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.*
|
||||
|
||||
class PollNoteViewModel {
|
||||
data class PollOption(
|
||||
val option: Int,
|
||||
val descriptor: String,
|
||||
val zappedValue: BigDecimal,
|
||||
val tally: BigDecimal,
|
||||
val consensusThreadhold: Boolean,
|
||||
val zappedByLoggedIn: Boolean
|
||||
)
|
||||
|
||||
class PollNoteViewModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
private var pollNote: Note? = null
|
||||
|
||||
var pollEvent: PollNoteEvent? = null
|
||||
private var pollOptions: Map<Int, String>? = null
|
||||
var pollOptions: Map<Int, String>? = null
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
private var closedAt: Int? = null
|
||||
var consensusThreshold: BigDecimal? = null
|
||||
|
||||
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
var wasZappedByAuthor: Boolean = false
|
||||
var wasZappedByLoggedInAccount: Boolean = false
|
||||
|
||||
var tallies by mutableStateOf<List<PollOption>>(emptyList())
|
||||
|
||||
fun load(acc: Account, note: Note?) {
|
||||
account = acc
|
||||
@@ -32,15 +47,35 @@ class PollNoteViewModel {
|
||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
|
||||
refreshTallies()
|
||||
}
|
||||
|
||||
fun refreshTallies() {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
|
||||
tallies = pollOptions?.keys?.map {
|
||||
val zappedInOption = zappedPollOptionAmount(it)
|
||||
|
||||
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
|
||||
|
||||
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
|
||||
|
||||
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !wasZappedByAuthor
|
||||
return user != note.author && !wasZappedByLoggedInAccount
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
@@ -88,14 +123,6 @@ class PollNoteViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
fun optionVoteTally(op: Int): BigDecimal {
|
||||
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
return pollNote!!.zaps
|
||||
.any {
|
||||
@@ -105,7 +132,7 @@ class PollNoteViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
private fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val event = it?.event as? LnZapEvent
|
||||
if (event?.zappedPollOption() == option) {
|
||||
@@ -116,7 +143,7 @@ class PollNoteViewModel {
|
||||
} ?: BigDecimal(0)
|
||||
}
|
||||
|
||||
fun totalZapped(): BigDecimal {
|
||||
private fun totalZapped(): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val zapEvent = (it?.event as? LnZapEvent)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ import kotlin.math.roundToInt
|
||||
@Composable
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
@@ -123,13 +123,21 @@ fun ReplyReaction(
|
||||
onPress: () -> Unit
|
||||
) {
|
||||
val repliesState by baseNote.live().replies.observeAsState()
|
||||
val replies = repliesState?.note?.replies ?: emptySet()
|
||||
val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
onPress()
|
||||
@@ -147,7 +155,7 @@ fun ReplyReaction(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
@@ -162,7 +170,7 @@ fun ReplyReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun BoostReaction(
|
||||
fun BoostReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -170,17 +178,32 @@ public fun BoostReaction(
|
||||
onQuotePress: () -> Unit
|
||||
) {
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
val boostedNote = boostsState?.note
|
||||
val boostedNote = remember(boostsState) { boostsState?.note } ?: return
|
||||
|
||||
val hasBoosted = remember(boostsState) { accountViewModel.hasBoosted(baseNote) }
|
||||
val wasBoostedByLoggedIn = remember(boostsState) { boostedNote.isBoostedBy(accountViewModel.userProfile()) }
|
||||
val isWriteable = remember { accountViewModel.isWriteable() }
|
||||
|
||||
val boostCount = remember(boostsState) { showCount(boostedNote.boosts.size) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wantsToBoost by remember { mutableStateOf(false) }
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.hasBoosted(baseNote)) {
|
||||
if (isWriteable) {
|
||||
if (hasBoosted) {
|
||||
accountViewModel.deleteBoostsTo(baseNote)
|
||||
} else {
|
||||
wantsToBoost = true
|
||||
@@ -210,25 +233,16 @@ public fun BoostReaction(
|
||||
)
|
||||
}
|
||||
|
||||
if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweet),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
tint = if (wasBoostedByLoggedIn) Color.Unspecified else grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(boostedNote?.boosts?.size)}",
|
||||
" $boostCount",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint
|
||||
)
|
||||
@@ -243,16 +257,30 @@ fun LikeReaction(
|
||||
heartSize: Dp = 16.dp
|
||||
) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
val reactedNote = reactionsState?.note ?: return
|
||||
val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return
|
||||
|
||||
val hasReacted = remember(reactionsState) { accountViewModel.hasReactedTo(baseNote) }
|
||||
val wasReactedByLoggedIn = remember(reactionsState) { reactedNote.isReactedBy(accountViewModel.userProfile()) }
|
||||
val isWriteable = remember { accountViewModel.isWriteable() }
|
||||
|
||||
val reactionCount = remember(reactionsState) { showCount(reactedNote.reactions.size) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(heartSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.hasReactedTo(baseNote)) {
|
||||
if (isWriteable) {
|
||||
if (hasReacted) {
|
||||
accountViewModel.deleteReactionTo(baseNote)
|
||||
} else {
|
||||
accountViewModel.reactTo(baseNote)
|
||||
@@ -268,25 +296,25 @@ fun LikeReaction(
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (reactedNote.isReactedBy(accountViewModel.userProfile())) {
|
||||
if (wasReactedByLoggedIn) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(heartSize),
|
||||
modifier = iconModifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
modifier = Modifier.size(heartSize),
|
||||
modifier = iconModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(reactedNote.reactions.size)}",
|
||||
" $reactionCount",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint
|
||||
)
|
||||
@@ -472,6 +500,19 @@ public fun ViewCountReaction(
|
||||
numberSize: Dp = 24.dp
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(barChartSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.height(numberSize)
|
||||
}
|
||||
|
||||
val colorFilter = remember {
|
||||
ColorFilter.tint(grayTint)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
@@ -480,21 +521,25 @@ public fun ViewCountReaction(
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BarChart,
|
||||
null,
|
||||
modifier = Modifier.size(barChartSize),
|
||||
modifier = iconButtonModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Row() {
|
||||
val request = remember {
|
||||
ImageRequest.Builder(context)
|
||||
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
}
|
||||
|
||||
Row {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build(),
|
||||
model = request,
|
||||
contentDescription = stringResource(R.string.view_count),
|
||||
modifier = Modifier.height(numberSize),
|
||||
colorFilter = ColorFilter.tint(grayTint)
|
||||
modifier = iconModifier,
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -59,8 +60,9 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
ClickableText(
|
||||
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
@@ -196,8 +198,9 @@ fun ReplyInformationChannel(
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
ClickableText(
|
||||
AnnotatedString("$prefix@${myUser.toBestDisplayName()}"),
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
|
||||
@@ -25,7 +25,17 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun UserCompose(
|
||||
baseUser: User,
|
||||
overallModifier: Modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -41,12 +51,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = overallModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||
|
||||
@@ -5,34 +5,31 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
|
||||
@Composable
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val author = remember(noteState) { noteState?.note?.author } ?: return
|
||||
|
||||
val author = note.author
|
||||
|
||||
if (author != null) {
|
||||
UsernameDisplay(author, weight)
|
||||
}
|
||||
UsernameDisplay(author, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
|
||||
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
|
||||
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
|
||||
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
val bestUserName = user.bestUsername()
|
||||
val bestDisplayName = user.bestDisplayName()
|
||||
val npubDisplay = user.pubkeyDisplayHex()
|
||||
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -40,31 +37,36 @@ private fun UserNameDisplay(
|
||||
bestUserName: String?,
|
||||
bestDisplayName: String?,
|
||||
npubDisplay: String,
|
||||
tags: List<List<String>>?,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null) {
|
||||
Text(
|
||||
bestDisplayName,
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"@$bestUserName",
|
||||
CreateTextWithEmoji(
|
||||
text = "@$bestUserName",
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (bestDisplayName != null) {
|
||||
Text(
|
||||
bestDisplayName,
|
||||
CreateTextWithEmoji(
|
||||
text = bestDisplayName,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (bestUserName != null) {
|
||||
Text(
|
||||
"@$bestUserName",
|
||||
CreateTextWithEmoji(
|
||||
text = "@$bestUserName",
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
|
||||
@@ -83,10 +84,10 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
|
||||
CreateTextWithEmoji(
|
||||
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
|
||||
tags = user.info?.latestMetadata?.tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,21 +40,22 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun startUI(key: String) {
|
||||
fun startUI(key: String, useProxy: Boolean, proxyPort: Int) {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val account =
|
||||
if (key.startsWith("nsec")) {
|
||||
Account(Persona(privKey = key.bechToBytes()))
|
||||
Account(Persona(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort)
|
||||
} else if (pubKeyParsed != null) {
|
||||
Account(Persona(pubKey = pubKeyParsed))
|
||||
Account(Persona(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort)
|
||||
} else if (pattern.matcher(key).matches()) {
|
||||
// Evaluate NIP-5
|
||||
Account(Persona())
|
||||
Account(Persona(), proxy = proxy, proxyPort = proxyPort)
|
||||
} else {
|
||||
Account(Persona(Hex.decode(key)))
|
||||
Account(Persona(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort)
|
||||
}
|
||||
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
@@ -66,8 +68,9 @@ class AccountStateViewModel() : ViewModel() {
|
||||
tryLoginExistingAccount()
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
val account = Account(Persona())
|
||||
fun newKey(useProxy: Boolean, proxyPort: Int) {
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
val account = Account(Persona(), proxy = proxy, proxyPort = proxyPort)
|
||||
// saves to local preferences
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
startUI(account)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -222,17 +223,19 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@@ -242,7 +245,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
clear()
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,13 @@ fun FeedView(
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
refreshing = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
refreshing = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -98,7 +99,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val oldNotesState = _feedContent.value
|
||||
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
|
||||
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
|
||||
updateFeed(newList)
|
||||
if (newList !== oldNotesState.feed.value) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
@@ -122,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,12 @@ import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PinListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -349,7 +352,13 @@ fun NoteMaster(
|
||||
)
|
||||
) {
|
||||
Column() {
|
||||
if (noteEvent is HighlightEvent) {
|
||||
if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
|
||||
} else if (noteEvent is AudioTrackEvent) {
|
||||
AudioTrackHeader(noteEvent, note, account.userProfile(), navController)
|
||||
} else if (noteEvent is PinListEvent) {
|
||||
PinListHeader(noteState, MaterialTheme.colors.background, accountViewModel, navController)
|
||||
} else if (noteEvent is HighlightEvent) {
|
||||
DisplayHighlight(
|
||||
noteEvent.quote(),
|
||||
noteEvent.author(),
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
@@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +386,6 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
account.joinChannel(channel.idHex)
|
||||
navController.navigate(Route.Message.route)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.richTextDefaults
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: MutableState<String>) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
onClose()
|
||||
})
|
||||
|
||||
val toastMessage = stringResource(R.string.invalid_port_number)
|
||||
|
||||
UseOrbotButton(
|
||||
onPost = {
|
||||
try {
|
||||
Integer.parseInt(portNumber.value)
|
||||
} catch (_: Exception) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
toastMessage,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return@UseOrbotButton
|
||||
}
|
||||
|
||||
onPost()
|
||||
},
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(30.dp)
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Row() {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.connect_through_your_orbot_setup_markdown)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = portNumber.value,
|
||||
onValueChange = { portNumber.value = it },
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
label = { Text(text = stringResource(R.string.orbot_socks_port)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "9050",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseOrbotButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.use_orbot), color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,14 @@ import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.buttons.NewChannelButton
|
||||
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.*
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
|
||||
@@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
skipHalfExpanded = true
|
||||
)
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account }
|
||||
|
||||
val followLists: FollowListViewModel = viewModel()
|
||||
followLists.load(account)
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetContent = {
|
||||
@@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
AppBottomBar(navController, accountViewModel)
|
||||
},
|
||||
topBar = {
|
||||
AppTopBar(navController, scaffoldState, accountViewModel)
|
||||
AppTopBar(followLists, navController, scaffoldState, accountViewModel)
|
||||
},
|
||||
drawerContent = {
|
||||
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
|
||||
@@ -101,7 +110,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV
|
||||
NewNoteButton(state.account, accountViewModel, navController)
|
||||
}
|
||||
if (currentRoute(navController) == Route.Message.base) {
|
||||
NewChannelButton(state.account)
|
||||
ChannelFabColumn(state.account, navController)
|
||||
}
|
||||
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
|
||||
NewImageButton(accountViewModel, navController)
|
||||
|
||||
+243
-223
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
@@ -18,7 +19,6 @@ import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
@@ -95,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -125,7 +127,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
@Composable
|
||||
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
|
||||
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
|
||||
@@ -139,6 +141,10 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
NostrUserProfileDataSource.start()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
@@ -214,64 +220,14 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
}
|
||||
) {
|
||||
val tabs = listOf<@Composable() (() -> Unit)?>(
|
||||
{
|
||||
Text(text = stringResource(R.string.notes))
|
||||
},
|
||||
{
|
||||
Text(text = stringResource(R.string.replies))
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollows = userState?.user?.transientFollowCount() ?: "--"
|
||||
|
||||
Text(text = "$userFollows ${stringResource(R.string.follows)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollowers = userState?.user?.transientFollowerCount() ?: "--"
|
||||
|
||||
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().zaps.observeAsState()
|
||||
val userZaps = userState?.user
|
||||
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempAmount = userZaps?.zappedAmount()
|
||||
withContext(Dispatchers.Main) {
|
||||
zapAmount = tempAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
val bookmarkList = userState?.user?.latestBookmarkList
|
||||
val userBookmarks =
|
||||
(bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0)
|
||||
|
||||
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().reports.observeAsState()
|
||||
val userReports = userState?.user?.reports?.values?.flatten()?.count()
|
||||
|
||||
Text(text = "$userReports ${stringResource(R.string.reports)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().relays.observeAsState()
|
||||
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
|
||||
|
||||
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
|
||||
val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
|
||||
|
||||
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
|
||||
}
|
||||
{ Text(text = stringResource(R.string.notes)) },
|
||||
{ Text(text = stringResource(R.string.replies)) },
|
||||
{ FollowTabHeader(baseUser) },
|
||||
{ FollowersTabHeader(baseUser) },
|
||||
{ ZapTabHeader(baseUser) },
|
||||
{ BookmarkTabHeader(baseUser) },
|
||||
{ ReportsTabHeader(baseUser) },
|
||||
{ RelaysTabHeader(baseUser) }
|
||||
)
|
||||
|
||||
tabs.forEachIndexed { index, function ->
|
||||
@@ -306,6 +262,72 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelaysTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().relays.observeAsState()
|
||||
val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" }
|
||||
|
||||
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
|
||||
val userRelays = remember(userStateRelayInfo) { userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" }
|
||||
|
||||
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportsTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().reports.observeAsState()
|
||||
val userReports = remember(userState) { userState?.user?.reports?.values?.flatten()?.count() }
|
||||
|
||||
Text(text = "$userReports ${stringResource(R.string.reports)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
val userBookmarks = remember(userState) {
|
||||
val bookmarkList = userState?.user?.latestBookmarkList
|
||||
(bookmarkList?.taggedEvents()?.count() ?: 0) + (
|
||||
bookmarkList?.taggedAddresses()?.count()
|
||||
?: 0
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().zaps.observeAsState()
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempAmount = baseUser.zappedAmount()
|
||||
withContext(Dispatchers.Main) {
|
||||
zapAmount = tempAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowersTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollowers = remember(userState) { userState?.user?.transientFollowerCount() ?: "--" }
|
||||
|
||||
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollows = remember(userState) { userState?.user?.transientFollowCount() ?: "--" }
|
||||
|
||||
Text(text = "$userFollows ${stringResource(R.string.follows)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeader(
|
||||
baseUser: User,
|
||||
@@ -316,9 +338,6 @@ private fun ProfileHeader(
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val accountUserState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@@ -398,23 +417,7 @@ private fun ProfileHeader(
|
||||
// No need for this button anymore
|
||||
// NPubCopyButton(baseUser)
|
||||
|
||||
if (accountUser == baseUser) {
|
||||
EditButton(account)
|
||||
}
|
||||
|
||||
if (account.isHidden(baseUser)) {
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
if (baseUser.isFollowingCached(accountUser)) {
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow_back)
|
||||
} else {
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow)
|
||||
}
|
||||
}
|
||||
ProfileActions(baseUser, account, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,37 +433,74 @@ private fun ProfileHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ProfileActions(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
val accountUserState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
|
||||
|
||||
if (accountUser == baseUser) {
|
||||
EditButton(account)
|
||||
}
|
||||
|
||||
if (account.isHidden(baseUser)) {
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
if (baseUser.isFollowingCached(accountUser)) {
|
||||
FollowButton(
|
||||
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
|
||||
R.string.follow_back
|
||||
)
|
||||
} else {
|
||||
FollowButton(
|
||||
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
|
||||
R.string.follow
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val userBadge = userBadgeState?.user ?: return
|
||||
val user = remember(userState) { userState?.user } ?: return
|
||||
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
user.bestDisplayName()?.let {
|
||||
Text(
|
||||
it,
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
(user.bestDisplayName() ?: user.bestUsername())?.let {
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = tags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 25.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
if (user.bestDisplayName() != null) {
|
||||
user.bestUsername()?.let {
|
||||
Text(
|
||||
"@$it",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
|
||||
)
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = "@$it",
|
||||
tags = tags,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,23 +553,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
val badgeAwardState by baseNote.live().metadata.observeAsState()
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisplayBadges(baseUser, navController)
|
||||
|
||||
DisplayNip05ProfileStatus(user)
|
||||
|
||||
@@ -552,9 +576,58 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
var zapExpanded by remember { mutableStateOf(false) }
|
||||
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
|
||||
val pubkeyHex = remember { baseUser.pubkeyHex }
|
||||
DisplayLNAddress(lud16, pubkeyHex, account, scope, context)
|
||||
|
||||
val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim()
|
||||
val identities = user.info?.latestMetadata?.identityClaims()
|
||||
if (!identities.isNullOrEmpty()) {
|
||||
identities.forEach { identity: IdentityClaim ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
tint = Color.Unspecified,
|
||||
painter = painterResource(id = identity.toIcon()),
|
||||
contentDescription = stringResource(identity.toDescriptor()),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(identity.identity),
|
||||
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.info?.about?.let {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = null,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayLNAddress(
|
||||
lud16: String?,
|
||||
userHex: String,
|
||||
account: Account,
|
||||
scope: CoroutineScope,
|
||||
context: Context
|
||||
) {
|
||||
var zapExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (!lud16.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -576,10 +649,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
|
||||
if (zapExpanded) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
baseUser.pubkeyHex,
|
||||
userHex,
|
||||
account,
|
||||
onSuccess = {
|
||||
// pay directly
|
||||
@@ -619,44 +695,34 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val identities = user.info?.latestMetadata?.identityClaims()
|
||||
if (!identities.isNullOrEmpty()) {
|
||||
identities.forEach { identity: IdentityClaim ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
tint = Color.Unspecified,
|
||||
painter = painterResource(id = identity.toIcon()),
|
||||
contentDescription = stringResource(identity.toDescriptor()),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun DisplayBadges(
|
||||
baseUser: User,
|
||||
navController: NavController
|
||||
) {
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(identity.identity),
|
||||
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
val badgeAwardState by baseNote.live().metadata.observeAsState()
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.info?.about?.let {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = null,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -727,9 +793,8 @@ fun BadgeThumb(
|
||||
@Composable
|
||||
private fun DrawBanner(baseUser: User) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val banner = remember(userState) { userState?.user?.info?.banner }
|
||||
|
||||
val banner = user.info?.banner
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -767,61 +832,51 @@ private fun DrawBanner(baseUser: User) {
|
||||
|
||||
@Composable
|
||||
fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesConversations(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(userState) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,41 +992,6 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NPubCopyButton(
|
||||
user: User
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { popupExpanded = true },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.copies_the_public_key_to_the_clipboard_for_sharing)
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
onDismissRequest = { popupExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) {
|
||||
Text(stringResource(R.string.copy_public_key_npub_to_the_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageButton(user: User, navController: NavController) {
|
||||
Button(
|
||||
|
||||
@@ -191,19 +191,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
val searchTextChanges = remember {
|
||||
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
|
||||
}
|
||||
|
||||
LaunchedEffect(db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
println("Search Active")
|
||||
searchBarViewModel.invalidateData()
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,7 +335,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
}
|
||||
}
|
||||
|
||||
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
|
||||
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)")
|
||||
|
||||
fun findHashtags(content: String): List<String> {
|
||||
val matcher = hashtagSearch.matcher(content)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedOff
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@@ -55,6 +57,9 @@ fun LoginPage(
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val useProxy = remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf("9050") }
|
||||
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -158,7 +163,7 @@ fun LoginPage(
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
try {
|
||||
accountViewModel.startUI(key.value.text)
|
||||
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt())
|
||||
} catch (e: Exception) {
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
@@ -221,6 +226,32 @@ fun LoginPage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isPackageInstalled(context, "org.torproject.android")) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = useProxy.value,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
connectOrbotDialogOpen = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.connect_via_tor))
|
||||
}
|
||||
|
||||
if (connectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
onClose = { connectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
connectOrbotDialogOpen = false
|
||||
useProxy.value = true
|
||||
},
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
|
||||
@@ -237,7 +268,7 @@ fun LoginPage(
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank()) {
|
||||
try {
|
||||
accountViewModel.startUI(key.value.text)
|
||||
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt())
|
||||
} catch (e: Exception) {
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
@@ -265,7 +296,7 @@ fun LoginPage(
|
||||
.fillMaxWidth(),
|
||||
onClick = {
|
||||
if (acceptedTerms.value) {
|
||||
accountViewModel.newKey()
|
||||
accountViewModel.newKey(useProxy.value, proxyPort.value.toInt())
|
||||
} else {
|
||||
termsAcceptanceIsRequired =
|
||||
context.getString(R.string.acceptance_of_terms_is_required)
|
||||
@@ -280,3 +311,7 @@ fun LoginPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector android:height="512dp" android:viewportHeight="24"
|
||||
android:viewportWidth="24" android:width="512dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M17.578,12.201c-0.76,-0.692 -1.721,-1.251 -2.704,-1.81 -0.446,-0.246 -1.81,-1.318 -1.34,-2.838l-0.851,-0.358c1.342,-2.078 3.085,-4.134 5.229,-6.056 -1.721,0.581 -3.24,1.476 -4.379,3.062 0.67,-1.407 1.765,-2.793 2.972,-4.201 -1.654,1.185 -3.084,2.525 -3.979,4.313l0.627,-2.503c-0.894,1.608 -1.52,3.24 -1.766,4.871l-1.317,-0.535 -0.223,0.178c1.162,2.078 0.559,3.174 -0.022,3.553 -1.162,0.783 -2.838,1.788 -3.688,2.659 -1.609,1.654 -2.078,3.218 -1.921,5.296 0.157,2.66 2.101,4.873 4.67,5.744 1.14,0.38 2.19,0.424 3.352,0.424 1.877,0 3.799,-0.491 5.207,-1.676a6.551,6.551 0,0 0,2.369 -5.027,6.875 6.875,0 0,0 -2.236,-5.096zM14.025,21.073c-0.09,0.402 -0.38,0.894 -0.737,1.341 0.134,-0.246 0.246,-0.492 0.313,-0.76 0.559,-1.989 0.805,-2.904 0.537,-5.095 -0.045,-0.224 -0.135,-0.938 -0.471,-1.721 -0.468,-1.185 -1.184,-2.303 -1.272,-2.548 -0.157,-0.38 -0.38,-1.989 -0.403,-3.084 0.023,0.938 0.089,2.659 0.335,3.329 0.067,0.225 0.715,1.229 1.185,2.459 0.312,0.849 0.38,1.632 0.446,1.854 0.224,1.007 -0.045,2.705 -0.401,4.313 -0.111,0.581 -0.426,1.252 -0.828,1.766 0.225,-0.313 0.402,-0.715 0.537,-1.185 0.269,-0.938 0.38,-2.145 0.356,-2.905 -0.021,-0.446 -0.222,-1.407 -0.558,-2.278 -0.201,-0.47 -0.492,-0.961 -0.692,-1.297 -0.224,-0.335 -0.224,-1.072 -0.313,-1.921 0.021,0.916 -0.068,1.385 0.156,2.033 0.134,0.379 0.625,0.916 0.759,1.43 0.201,0.693 0.402,1.453 0.381,1.922 0,0.536 -0.022,1.52 -0.269,2.593 -0.157,0.804 -0.515,1.497 -1.095,1.943 0.246,-0.312 0.38,-0.625 0.447,-0.938 0.089,-0.469 0.111,-0.916 0.156,-1.475a5.96,5.96 0,0 0,-0.111 -1.721c-0.179,-0.805 -0.469,-1.608 -0.604,-2.168 0.022,0.626 0.269,1.408 0.381,2.235 0.089,0.604 0.044,1.206 0.021,1.742 -0.021,0.627 -0.223,1.722 -0.492,2.258 -0.268,-0.112 -0.357,-0.269 -0.537,-0.491 -0.223,-0.291 -0.357,-0.604 -0.491,-0.962a5.043,5.043 0,0 1,-0.291 -0.915,3.071 3.071,0 0,1 0.559,-2.213c0.469,-0.671 0.559,-0.716 0.715,-1.497 -0.223,0.692 -0.379,0.759 -0.871,1.341 -0.559,0.647 -0.648,1.586 -0.648,2.346 0,0.313 0.134,0.671 0.246,1.007 0.134,0.356 0.268,0.714 0.447,0.982 0.134,0.223 0.313,0.379 0.469,0.491 -0.581,-0.156 -1.184,-0.379 -1.564,-0.692 -0.938,-0.805 -1.765,-2.167 -1.877,-3.375 -0.089,-0.982 0.804,-2.413 2.078,-3.128 1.073,-0.626 1.318,-1.319 1.542,-2.459 -0.313,0.983 -0.626,1.833 -1.654,2.348 -1.475,0.804 -2.235,2.1 -2.167,3.352 0.112,1.586 0.737,2.682 2.011,3.554 0.291,0.2 0.693,0.401 1.118,0.559 -1.587,-0.381 -1.788,-0.604 -2.324,-1.229 0,-0.045 -0.134,-0.135 -0.134,-0.156 -0.715,-0.805 -1.609,-2.19 -1.922,-3.464 -0.112,-0.447 -0.224,-0.916 -0.089,-1.363 0.581,-2.101 1.854,-2.905 3.128,-3.775 0.313,-0.225 0.626,-0.426 0.916,-0.649 0.715,-0.559 0.894,-2.012 1.05,-2.838 -0.29,1.006 -0.603,2.258 -1.162,2.659 -0.29,0.224 -0.648,0.402 -0.938,0.604 -1.318,0.894 -2.637,1.743 -3.24,3.91 -0.134,0.56 -0.044,0.962 0.089,1.498 0.335,1.317 1.229,2.748 1.989,3.597l0.134,0.135c0.335,0.381 0.76,0.67 1.274,0.871a5.945,5.945 0,0 1,-1.296 -0.469c-2.078,-1.005 -3.463,-3.173 -3.553,-4.939 -0.179,-3.597 1.542,-4.647 3.151,-5.966 0.894,-0.737 2.145,-1.095 2.86,-2.413 0.134,-0.291 0.224,-0.916 0.045,-1.587 -0.067,-0.224 -0.402,-1.028 -0.537,-1.207l1.989,0.872c-0.044,0.938 -0.067,1.698 0.112,2.391 0.2,0.76 1.184,1.854 1.586,3.129 0.783,2.41 0.583,5.561 0.023,8.019z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,357 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name_release" translatable="false">Amethyst</string>
|
||||
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
|
||||
<string name="point_to_the_qr_code"> به QRکد اشاره کنید</string>
|
||||
<string name="show_qr">QR کد را نشان بده </string>
|
||||
<string name="profile_image">تصویر پروفایل</string>
|
||||
<string name="scan_qr">اسکن QRکد </string>
|
||||
<string name="show_anyway">بهر حال نشان بده</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">این نوت توسط این افراد نامناسب اعلام شده</string>
|
||||
<string name="post_not_found">این نوت یافت نشد</string>
|
||||
<string name="channel_image">تصویر کانال</string>
|
||||
<string name="referenced_event_not_found">رویداد مربوطه یافت نشد</string>
|
||||
<string name="could_not_decrypt_the_message">قادر به رمزگشایی پیام نیست</string>
|
||||
<string name="group_picture">تصویر گروه</string>
|
||||
<string name="explicit_content">محتوای نامناسب</string>
|
||||
<string name="spam">اسپم</string>
|
||||
<string name="impersonation">جعل هویت</string>
|
||||
<string name="illegal_behavior">رفتار ناهنجار</string>
|
||||
<string name="unknown">ناشناس</string>
|
||||
<string name="relay_icon">آیکن رله</string>
|
||||
<string name="unknown_author">نویسنده ناشناس</string>
|
||||
<string name="copy_text">کپی متن</string>
|
||||
<string name="copy_user_pubkey">کپی شناسه نویسنده</string>
|
||||
<string name="copy_note_id">کپی شناسه نوت</string>
|
||||
<string name="broadcast">انتشار</string>
|
||||
<string name="request_deletion">درخواست حذف/string></string>
|
||||
<string name="block_hide_user">بلاک/پنهان کردن کاربر</string>
|
||||
<string name="report_spam_scam">گزارش اسپم/کلاهبرداری</string>
|
||||
<string name="report_impersonation">گزارش جعل هویت</string>
|
||||
<string name="report_explicit_content">گزارش محتوای نامناسب</string>
|
||||
<string name="report_illegal_behaviour">گزارش رفتار ناهنجار</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">با کلید خصوصی وارد شوید تا بتوانید نوت را بازارسال کنید</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">با کلید خصوصی وارد شوید تا بتوانید نوت را لایک کنید</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">مقداری برای زپ تعیین نشده. نگه دارید تا تغییر دهید</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">با کلید خصوصی وارد شوید تا بتوانید زپ کنید</string>
|
||||
<string name="zaps">زپ</string>
|
||||
<string name="view_count">دیدن تعداد</string>
|
||||
<string name="boost">بازارسال</string>
|
||||
<string name="boosted">بازارسال شده</string>
|
||||
<string name="quote">نقل قول</string>
|
||||
<string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string>
|
||||
<string name="add">افزودن</string>
|
||||
<string name="replying_to">" پاسخ به"</string>
|
||||
<string name="and">" و "</string>
|
||||
<string name="in_channel">"در کانال "</string>
|
||||
<string name="profile_banner">بنر پروفایل</string>
|
||||
<string name="following">"دنبال شونده"</string>
|
||||
<string name="followers">"دنبال کننده"</string>
|
||||
<string name="profile">پروفایل</string>
|
||||
<string name="security_filters">فیلترهای ایمنی</string>
|
||||
<string name="log_out">خروج</string>
|
||||
<string name="show_more">ادامه</string>
|
||||
<string name="lightning_invoice">اینویس لایتنینگ</string>
|
||||
<string name="pay">پرداخت</string>
|
||||
<string name="lightning_tips">انعام لایتنینگ</string>
|
||||
<string name="note_to_receiver">یادداشت برای گیرنده</string>
|
||||
<string name="thank_you_so_much">!خیلی ممنون</string>
|
||||
<string name="amount_in_sats">مبلغ به ساتوشی</string>
|
||||
<string name="send_sats">ارسال ساتوشی</string>
|
||||
<string name="error_parsing_preview_for">" %1$s : %2$s خطا در تبدیل"</string>
|
||||
<string name="preview_card_image_for">"%1$s پیش نمایش تصویر کارت برای"</string>
|
||||
<string name="new_channel">کانال جدید</string>
|
||||
<string name="channel_name">نام کانال</string>
|
||||
<string name="my_awesome_group">گروه باحال من</string>
|
||||
<string name="picture_url">آدرس تصویر</string>
|
||||
<string name="description">توصیف</string>
|
||||
<string name="about_us">"درباره ما.. "</string>
|
||||
<string name="what_s_on_your_mind">به چی فکر میکنی؟</string>
|
||||
<string name="post">ارسال</string>
|
||||
<string name="save">ذخیره</string>
|
||||
<string name="create">ساختن</string>
|
||||
<string name="cancel">لغو</string>
|
||||
<string name="failed_to_upload_the_image">تصویر بارگذاری نشد</string>
|
||||
<string name="relay_address">آدرس رله</string>
|
||||
<string name="posts">نوت ها</string>
|
||||
<string name="bytes">بایت</string>
|
||||
<string name="errors">خطاها</string>
|
||||
<string name="home_feed">خبرنامه اصلی</string>
|
||||
<string name="private_message_feed">پیام خصوصی</string>
|
||||
<string name="public_chat_feed">گفتگوی عمومی</string>
|
||||
<string name="global_feed">خبرنامه همگانی</string>
|
||||
<string name="search_feed">جستجو در خبرنامه</string>
|
||||
<string name="add_a_relay">افزودن رله</string>
|
||||
<string name="display_name">نمایش نام</string>
|
||||
<string name="my_display_name">نام من</string>
|
||||
<string name="username">نام کاربری</string>
|
||||
<string name="my_username">نام کاربری من</string>
|
||||
<string name="about_me">درباره من</string>
|
||||
<string name="avatar_url">آدرس آواتار</string>
|
||||
<string name="banner_url">آدرس بنر</string>
|
||||
<string name="website_url">آدری وبسایت</string>
|
||||
<string name="ln_address">آدرس لایتنینگ</string>
|
||||
<string name="ln_url_outdated">آدرس لایتنینگ(نقل شده)</string>
|
||||
<string name="image_saved_to_the_gallery">تصویر در گالری ذخیره شد</string>
|
||||
<string name="failed_to_save_the_image">تصویر ذخیره نشد</string>
|
||||
<string name="upload_image">بارگذاری تصویر</string>
|
||||
<string name="uploading">...در حال بارگذاری</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">این کاربر آدرس لایتنینگ برای دریافت ساتوشی تنظیم نکرده است</string>
|
||||
<string name="reply_here">"...اینجا پاسخ دهید "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">شناسه نوت را برای اشتراک گذاری در کلیپبورد ذخیره می کند </string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">ذخیره شناسه کانال در کلیپبورد</string>
|
||||
<string name="edits_the_channel_metadata">متادیتای کانال را ذخیره می کند</string>
|
||||
<string name="join">پیوستن</string>
|
||||
<string name="known">شناس</string>
|
||||
<string name="new_requests">در خواست جدید</string>
|
||||
<string name="blocked_users">کاربران بلاک شده</string>
|
||||
<string name="new_threads">رشته جدید</string>
|
||||
<string name="conversations">گفتگوها</string>
|
||||
<string name="notes">نوت ها</string>
|
||||
<string name="replies">پاسخ ها</string>
|
||||
<string name="follows">"دنبال شوندگان"</string>
|
||||
<string name="reports">"گزارش ها"</string>
|
||||
<string name="more_options">انتخاب های بیشتر</string>
|
||||
<string name="relays">"رله ها"</string>
|
||||
<string name="website">وبسایت</string>
|
||||
<string name="lightning_address">آدرس لایتنینگ</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">شناسه کلید خصوصی (پسوردتان) را در کلیپبورد کپی می کند(</string>
|
||||
<string name="copy_private_key_to_the_clipboard">کپی کردن کلید خصوصی در کلیپبورد</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">کلید عمومی را برای اشتراک گذاری در کلیپبورد ذخیره می کند</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">کپی کردن کلید عمومی در کلیپبورد</string>
|
||||
<string name="send_a_direct_message">ارسال پیام مستقیم</string>
|
||||
<string name="edits_the_user_s_metadata">متادیتای کاربر را ویرایش می کند</string>
|
||||
<string name="follow">دنبال کردن</string>
|
||||
<string name="follow_back">دنبال کردن متقابل</string>
|
||||
<string name="unblock">از بلاک درآوردن</string>
|
||||
<string name="copy_user_id">کپی شناسه کاربر</string>
|
||||
<string name="unblock_user">از بلاک درآوردن کاربر</string>
|
||||
<string name="npub_hex_username">"npub, hex, نام کاربری "</string>
|
||||
<string name="clear">پاک کردن</string>
|
||||
<string name="app_logo">لوگوی اپ</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec / npub / hex کلید خصوصی</string>
|
||||
<string name="show_password">نشان دادن پسورد</string>
|
||||
<string name="hide_password">پنهان کردن پسورد</string>
|
||||
<string name="invalid_key">کلید نامعتبر</string>
|
||||
<string name="i_accept_the">"می پذیرم "</string>
|
||||
<string name="terms_of_use">شرایط استفاده</string>
|
||||
<string name="acceptance_of_terms_is_required">پذیرش شرایط الازمیست</string>
|
||||
<string name="key_is_required">کلید الزامیست</string>
|
||||
<string name="login">ورود</string>
|
||||
<string name="generate_a_new_key">ساختن جفت کلید جدید</string>
|
||||
<string name="loading_feed">در حال بارگذاری خبرنامه</string>
|
||||
<string name="error_loading_replies">" :خطا در بارگذاری پاسخ ها"</string>
|
||||
<string name="try_again">دوباره تلاش کنید</string>
|
||||
<string name="feed_is_empty">خبرنامه خالیست</string>
|
||||
<string name="refresh">تازه سازی</string>
|
||||
<string name="created">ساخته شد</string>
|
||||
<string name="with_description_of">با توصیف</string>
|
||||
<string name="and_picture">و تصویر</string>
|
||||
<string name="changed_chat_name_to">نام گفتگو تغییر کرد به</string>
|
||||
<string name="description_to">تصویر</string>
|
||||
<string name="and_picture_to">و تصویر به</string>
|
||||
<string name="leave">ترک کردن</string>
|
||||
<string name="unfollow">دنبال نکردن</string>
|
||||
<string name="channel_created">کانال ساخته شد</string>
|
||||
<string name="channel_information_changed_to">"اطلاعات کانال تغییر کرد به"</string>
|
||||
<string name="public_chat">گفتگوی عمومی</string>
|
||||
<string name="posts_received">نوت های دریافت شده</string>
|
||||
<string name="remove">بیرون کردن</string>
|
||||
<string name="sats" translatable="false">ساتوشی</string>
|
||||
<string name="translations_auto">خودکار</string>
|
||||
<string name="translations_translated_from">ترجمه از</string>
|
||||
<string name="translations_to">به</string>
|
||||
<string name="translations_show_in_lang_first">ابتدا به نشان بده %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">همیشه به ترجمه کن %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">هرگز به ترجمه نکن %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">هرگز</string>
|
||||
<string name="now">اکنون</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">برهنگی</string>
|
||||
<string name="profanity_hateful_speech">ناسزا / نفرت پراکنی</string>
|
||||
<string name="report_hateful_speech">گزارش نفرت پراکنی</string>
|
||||
<string name="report_nudity_porn">گزارش برهنگی / پورن</string>
|
||||
<string name="others">سایر</string>
|
||||
<string name="mark_all_known_as_read">همه شناس ها خوانده شده کن</string>
|
||||
<string name="mark_all_new_as_read">همه جدیدها را خوانده شده کن</string>
|
||||
<string name="mark_all_as_read">همه را خوانده شده کن</string>
|
||||
<string name="backup_keys">بازیابی کلیدها</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">
|
||||
## راهنمای ایمنی و بازیابی کلید
|
||||
\n\n- حساب کاربری شما با یک کلید خصوصی ایمن می شود. این کلید یک رشته تصادفی اعداد و ارقام است که با **nsec1** آغاز می گردد.
|
||||
\n\n- هر کس به کلید خصوصی شما دست یابد می تواند با هویت شما اقدام به انتشار محتوا نماید. کلید خصوصی خود را در هیچ نرم افزار یا وب سایتی که به آن اعتماد ندارید قرار ندهید.
|
||||
\n- سازندگان آماتیست **هرگز** از شما کلید خصوصی تان را نمی خواهند.
|
||||
\n- حتما یک کپی از کلید خصوصی را برای بازیابی حساب کاربری نزد خود نگه دارد. ما استفاده از یک نرم افزار مدیریت پسورد را توصیه می نماییم.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">کلید خصوصی در کلیپبورد کپی شد</string>
|
||||
<string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string>
|
||||
<string name="biometric_authentication_failed">صحت سنجی انجام نشد</string>
|
||||
<string name="biometric_error">خطا</string>
|
||||
<string name="badge_created_by">"ساخته شده توسط %1$s"</string>
|
||||
<string name="badge_award_image_for">"تصویر مدال پاداش برای %1$s"</string>
|
||||
<string name="new_badge_award_notif">یک مدال پاداش دریافت کردید</string>
|
||||
<string name="award_granted_to">مدال پاداش داده شد به</string>
|
||||
<string name="copied_note_text_to_clipboard">نوت در کلیپبورد کپی شد</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">@npubنویسنده در کلیپبورد کپی شد </string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">(@note1)شناسه نوت در کلیپبورد کپی شد</string>
|
||||
<string name="select_text_dialog_top">انتخاب متن</string>
|
||||
<string name="github" translatable="false">Github Gist w/ Proof</string>
|
||||
<string name="telegram" translatable="false">Telegram</string>
|
||||
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
|
||||
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
|
||||
<string name="github_proof_url_template" translatable="false">https://gist.github.com/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<نمی توان پیام خصوصی را رمزگشایی کردن>\n\nYou در پیام خصوصی/رمزگذاری شده بین و به شما اشاره شده %1$s %2$s."</string>
|
||||
<string name="account_switch_add_account_dialog_title">افزودن حساب کاربری جدید</string>
|
||||
<string name="drawer_accounts">حساب های کاربری</string>
|
||||
<string name="account_switch_select_account">انتخاب حساب کارگری</string>
|
||||
<string name="account_switch_add_account_btn">افزودن حساب کاربری جدید</string>
|
||||
<string name="account_switch_active_account">حساب کاربری فعال</string>
|
||||
<string name="account_switch_has_private_key">کلید خصوصی دارد</string>
|
||||
<string name="account_switch_pubkey_only">فقط خواندن، کلید خصوصی ندارد</string>
|
||||
<string name="back">بازگشت</string>
|
||||
<string name="quick_action_select">انتخاب</string>
|
||||
<string name="quick_action_share_browser_link">اشتراک گذاری لینک پیماینده</string>
|
||||
<string name="quick_action_share">اشتراک گذاری</string>
|
||||
<string name="quick_action_copy_user_id">شناسه نویسنده</string>
|
||||
<string name="quick_action_copy_note_id">شناسه نوت</string>
|
||||
<string name="quick_action_copy_text">کپی متن</string>
|
||||
<string name="quick_action_delete">حذف</string>
|
||||
<string name="quick_action_unfollow">دنبال نکردن</string>
|
||||
<string name="quick_action_follow">دنبال کردن</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
|
||||
<string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که نوت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که نوت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </string>
|
||||
<string name="quick_action_block_dialog_btn">بلاک</string>
|
||||
<string name="quick_action_delete_dialog_btn">حذف</string>
|
||||
<string name="quick_action_block">بلاک</string>
|
||||
<string name="quick_action_report">گزارش</string>
|
||||
<string name="quick_action_delete_button">حذف</string>
|
||||
<string name="quick_action_dont_show_again_button">دوباره نشان نده</string>
|
||||
<string name="report_dialog_spam">اسپم یا کلاهبرداری</string>
|
||||
<string name="report_dialog_profanity">محتوای نفرت پراکنی یا ناسزا</string>
|
||||
<string name="report_dialog_impersonation">جعل هویت بدخواهانه</string>
|
||||
<string name="report_dialog_nudity">محتوای برهنگی یا نمایان</string>
|
||||
<string name="report_dialog_illegal">رفتار ناهنجار</string>
|
||||
<string name="report_dialog_blocking_a_user">بلاک کردن کاربر محتوای ایشان را در اپ شما پنهان می کند. نوت های شما همچنان بطور عمومی دیده می شوند که شامل کاربران بلاک شده توسط شما نیز هست. کاربران بلاک شده در صفحه فیلترهای ایمنی فهرست می شوند.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Block & Hide User]]></string>
|
||||
<string name="report_dialog_report_btn">گزارش سواستفاده</string>
|
||||
<string name="report_dialog_reminder_public">تمام گزارش ها بطور عمومی دیده می شوند</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">در صورت تمایل توضیحاتی به گزارش خود بیفزایید...</string>
|
||||
<string name="report_dialog_additional_reason_label">توضیحات اضافه</string>
|
||||
<string name="report_dialog_select_reason_label">دلایل</string>
|
||||
<string name="report_dialog_select_reason_placeholder">دلیلی انتخاب کنید...</string>
|
||||
<string name="report_dialog_post_report_btn">گزارش نوت</string>
|
||||
<string name="report_dialog_title">بلاک و گزارش</string>
|
||||
<string name="block_only">بلاک</string>
|
||||
|
||||
<string name="bookmarks">علامت گذاشته ها</string>
|
||||
<string name="private_bookmarks">علامت گاشته های خصوصی</string>
|
||||
<string name="public_bookmarks">علامت گذاشته های عمومی</string>
|
||||
<string name="add_to_private_bookmarks">افزودن به علامت گذاشته های خصوصی</string>
|
||||
<string name="add_to_public_bookmarks">افزودن به علامت گذاشته های عمومی</string>
|
||||
<string name="remove_from_private_bookmarks">برداشتن از علامت گذاشته های خصوصی</string>
|
||||
<string name="remove_from_public_bookmarks">برداشتن از علامت گذاشته های خصوصی</string>
|
||||
|
||||
<string name="wallet_connect_service">خدمات اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_explainer">اجازه رمز نوستر برای پرداخت زپ بدون خارج شدن از اپ. رمز را ایمن نگه دارید و در صورت امکان از رله خصوصی استفاده کنید.</string>
|
||||
<string name="wallet_connect_service_pubkey">کلید عمومی اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_relay">رله اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_secret">رمز اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_show_secret">نمایش کلید خصوصی</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex کلید خصوصی</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">مبلغ وثیقه به ساتوشی</string>
|
||||
<string name="post_poll">ارسال نظرسنجی</string>
|
||||
<string name="poll_heading_required">قسمت های الزامی:</string>
|
||||
<string name="poll_zap_recipients">دریافت کنندگان زپ</string>
|
||||
<string name="poll_primary_description">توضیحات اولیه نظرسنجی…</string>
|
||||
<string name="poll_option_index">گزینه ها %s</string>
|
||||
<string name="poll_option_description">توضیح گزینه های نظرسنجی</string>
|
||||
<string name="poll_heading_optional">قسمت های اختیاری:</string>
|
||||
<string name="poll_zap_value_min">حداقل زپ</string>
|
||||
<string name="poll_zap_value_max">حداکثر زپ</string>
|
||||
<string name="poll_consensus_threshold">توافق نظر</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">بسته شدن پس از</string>
|
||||
<string name="poll_closing_time_days">روز</string>
|
||||
<string name="poll_is_closed">نظر سنجی برای رای جدید بسته است</string>
|
||||
<string name="poll_zap_amount">مبلغ زپ</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">فقط یک رای به ازای هر کاربر در این نوع نظرسنجی مجاز است</string>
|
||||
|
||||
<string name="looking_for_event">"جستجوی رویداد %1$s"</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">افزودن پیام عمومی</string>
|
||||
<string name="custom_zaps_add_a_message_private">افزودن پیام خصوصی</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">افزودن پیام اینویس</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message_example">از زحمات شما متشکریم!</string>
|
||||
|
||||
<string name="lightning_create_and_add_invoice">ساختن و افزودن</string>
|
||||
<string name="poll_author_no_vote">سازندگان نظرسنجی نمی توانند در نظر سنجی خود رای دهند.</string>
|
||||
<string name="poll_hashtag" translatable="false">#زپ_نظرسنجی</string>
|
||||
|
||||
|
||||
<string name="hash_verification_passed">تصویر از زمان ارسال یکسان است</string>
|
||||
<string name="hash_verification_failed">تصویر تغییر کرده است. ممکن است نویسنده تغییر را ندیده باشد.</string>
|
||||
|
||||
<string name="content_description_add_image">افزودن تصویر</string>
|
||||
<string name="content_description_add_video">افزودن فیلم</string>
|
||||
<string name="content_description_add_document">افزودن مدارک</string>
|
||||
|
||||
<string name="add_content">افزودن به پیام</string>
|
||||
<string name="content_description">توصیف محتوا</string>
|
||||
<string name="content_description_example">قایقی آبی در ساحل شنی سفید هنگام غروب</string>
|
||||
|
||||
<string name="zap_type">نوع زپ</string>
|
||||
<string name="zap_type_explainer">نوع زپ برای همه گزینه ها</string>
|
||||
|
||||
<string name="zap_type_public">عمومی</string>
|
||||
<string name="zap_type_public_explainer">همه می توانند تراکنش و پیام را ببینند</string>
|
||||
|
||||
<string name="zap_type_private">خصوصی</string>
|
||||
<string name="zap_type_private_explainer">فرستنده و گیرنده می توانند یکدیگر و پیام را ببینند</string>
|
||||
|
||||
<string name="zap_type_anonymous">ناشناس</string>
|
||||
<string name="zap_type_anonymous_explainer">گیرنده و دیگران نمی دانند چه کسی پرداخت کرده است</string>
|
||||
|
||||
<string name="zap_type_nonzap">غیرزپ</string>
|
||||
<string name="zap_type_nonzap_explainer">هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود</string>
|
||||
|
||||
|
||||
<string name="file_server">سرور فایل</string>
|
||||
<string name="zap_forward_lnAddress">آدرس لایتنینگ یا @User</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - معتمد</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur میتواند فایل را بهینه کند</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - معتمد</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg میتواند فایل را بهینه کند</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - معتمد</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string>
|
||||
|
||||
<string name="upload_server_imgur_nip94">قابل بررسی Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">بررسی کنید که Imgur فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. </string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">قابل بررسی NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">بررسی کنید که NostrImg فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند.</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">بررسی کنید که Nostr.build فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند.</string>
|
||||
|
||||
<string name="upload_server_relays_nip95">رله های شما (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر </string>
|
||||
|
||||
|
||||
|
||||
<string name="follow_list_selection">لیست دنبال ها </string>
|
||||
<string name="follow_list_kind3follows">همه دنبال ها</string>
|
||||
<string name="follow_list_global">همگانی</string>
|
||||
|
||||
</resources>
|
||||
@@ -333,11 +333,45 @@
|
||||
|
||||
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<string name="follow_list_selection">Követek Lista</string>
|
||||
<string name="follow_list_kind3follows">Mindenki akit követek</string>
|
||||
<string name="follow_list_global">Globális</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Kapcsolódj a TOR-on keresztül az Orbot segítségével
|
||||
\n\n1. Telepítsd az [Orbot-ot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Indítsd el az Orbot-ot
|
||||
\n3. Az Orbot-ban ellenőrizd a Socks portokat. Az alapbeállítás 9050
|
||||
\n4. Ha szükséges az Orbot-ban változtasd meg a portot
|
||||
\n5. Ezen a felületen állítsd be a Socks portot
|
||||
\n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks portja</string>
|
||||
<string name="invalid_port_number">Érvénytelen Port szám</string>
|
||||
<string name="use_orbot">Használd az Orbot-ot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">TOR/Orbot Lekapcsolása</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
|
||||
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
|
||||
|
||||
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
|
||||
<string name="app_notification_dms_channel_name">Privát üzenetek</string>
|
||||
<string name="app_notification_dms_channel_description">Értesítést küld, amikor privát üzeneted érkezik</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
|
||||
<string name="app_notification_zaps_channel_name">Zap-et kaptál</string>
|
||||
<string name="app_notification_zaps_channel_description">Értesítést küld, amikor valaki neked Zap-et küldött</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">%1$s -tól</string>
|
||||
<string name="app_notification_zaps_channel_message_for">%1$s -ért</string>
|
||||
|
||||
<string name="reply_notify">Értesítés: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Csatlakozz a beszélgetéshez</string>
|
||||
<string name="channel_list_user_or_group_id">Felhasználó vagy Csoport azonosító</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent vagy hex</string>
|
||||
<string name="channel_list_create_channel">Létrehoz</string>
|
||||
<string name="channel_list_join_channel">Csatlakozás</string>
|
||||
</resources>
|
||||
|
||||
@@ -318,18 +318,35 @@
|
||||
<string name="file_server">File server</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress of @User</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - minder beveiligd</string>
|
||||
<string name="upload_server_imgur_explainer">Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen</string>
|
||||
<string name="upload_server_imgur">imgur.com - vertrouwd</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - minder beveiligd</string>
|
||||
<string name="upload_server_nostrimg_explainer">Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com - vertrouwd</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - vertrouwd</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - vertrouwd</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_imgur_nip94">Verifieerbare Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Checkt of Imgur het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">Verifieerbare NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Checkt of NostrImg het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifieerbare Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Checkt of Nostr.build het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev_nip94">Verifieerbare Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Checkt of Nostrfiles.dev het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_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="follow_list_selection">Volgerslijst</string>
|
||||
<string name="follow_list_kind3follows">Alle gevolgden</string>
|
||||
<string name="follow_list_global">Globaal</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -234,4 +234,91 @@
|
||||
<string name="report_dialog_post_report_btn">Отправить жалобу</string>
|
||||
<string name="private_conversation_notification">"<Не удалось расшифровать сообщение>\n\nВас упомянули в приватном/зашифрованном чате между %1$s и %2$s."</string>
|
||||
<string name="report_dialog_title">Заблокировать и пожаловаться</string>
|
||||
<string name="bytes">Байт</string>
|
||||
<string name="follow_back">Подписаться в ответ</string>
|
||||
<string name="bookmarks">Закладки</string>
|
||||
<string name="private_bookmarks">Личные закладки</string>
|
||||
<string name="public_bookmarks">Публичные закладки</string>
|
||||
<string name="add_to_private_bookmarks">Добавить в Личные закладки</string>
|
||||
<string name="add_to_public_bookmarks">Добавить в Публичные закладки</string>
|
||||
<string name="remove_from_private_bookmarks">Убрать из Личных закладок</string>
|
||||
<string name="remove_from_public_bookmarks">Убрать из Публичных закладок</string>
|
||||
<string name="wallet_connect_service">Служба Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Позволяет оплачивать запы с помощью секрета, не выходя из приложения. Храните секрет в безопасности и по возможности используйте приватный релей</string>
|
||||
<string name="wallet_connect_service_pubkey">Публичный ключ Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Релей Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Секрет Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Показать секрет</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / приватный ключ в hex</string>
|
||||
<string name="pledge_amount_in_sats">Сумма взноса в sat</string>
|
||||
<string name="post_poll">Отправить опрос</string>
|
||||
<string name="poll_heading_required">Обязательные поля:</string>
|
||||
<string name="poll_zap_recipients">Получатели запов</string>
|
||||
<string name="poll_primary_description">Описание опроса</string>
|
||||
<string name="poll_option_index">Вариант %s</string>
|
||||
<string name="poll_option_description">Описание варианта</string>
|
||||
<string name="poll_heading_optional">Опциональные поля:</string>
|
||||
<string name="poll_zap_value_min">Минимальный зап</string>
|
||||
<string name="poll_zap_value_max">Максимальный зап</string>
|
||||
<string name="poll_consensus_threshold">Консенсус</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Закрытие после</string>
|
||||
<string name="poll_closing_time_days">дней</string>
|
||||
<string name="poll_is_closed">Опрос закрыт</string>
|
||||
<string name="poll_zap_amount">Сумма запа</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">В этом опросе можно голосовать только один раз</string>
|
||||
<string name="looking_for_event">Поиск события %1$s</string>
|
||||
<string name="custom_zaps_add_a_message">Добавить публичное сообщение</string>
|
||||
<string name="custom_zaps_add_a_message_private">Добавить личное сообщение</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Добавить примечание к инвойсу</string>
|
||||
<string name="custom_zaps_add_a_message_example">Спасибо всем вам за работу!</string>
|
||||
<string name="lightning_create_and_add_invoice">Создать и Добавить</string>
|
||||
<string name="poll_author_no_vote">Автор опроса не может голосовать</string>
|
||||
<string name="hash_verification_passed">Фото не изменилось после публикации</string>
|
||||
<string name="hash_verification_failed">Фото изменено. Автор мог не заметить изменение</string>
|
||||
<string name="content_description_add_image">Добавить фото</string>
|
||||
<string name="content_description_add_video">Добавить видео</string>
|
||||
<string name="content_description_add_document">Добавить документ</string>
|
||||
<string name="add_content">Добавить к сообщению</string>
|
||||
<string name="content_description">Описание содержимого</string>
|
||||
<string name="content_description_example">Компания весёлых молодых людей</string>
|
||||
<string name="zap_type">Тип запа</string>
|
||||
<string name="zap_type_explainer">Тип запа для всех вариантов</string>
|
||||
<string name="zap_type_public">Публичный</string>
|
||||
<string name="zap_type_public_explainer">Все видят платеж и сообщение</string>
|
||||
<string name="zap_type_private">Приватный</string>
|
||||
<string name="zap_type_private_explainer">Отправитель и получатель видят друг друга и могут прочесть сообщение</string>
|
||||
<string name="zap_type_anonymous">Анонимный</string>
|
||||
<string name="zap_type_anonymous_explainer">Никто не видит отправителя платежа</string>
|
||||
<string name="zap_type_nonzap">Не-зап</string>
|
||||
<string name="zap_type_nonzap_explainer">Без следа в Nostr, обычный Lightning платеж</string>
|
||||
<string name="file_server">Сервер для загрузки</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress или @User</string>
|
||||
<string name="upload_server_imgur">imgur.com – доверенный</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur может изменять файл</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com – доверенный</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg может изменять файл</string>
|
||||
<string name="upload_server_imgur_nip94">Верифицируемый Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Проверка на изменение файла Imgur. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_nostrimg_nip94">Верифицируемый NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Проверка на изменение файла NostrImg. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_relays_nip95">Ваши релеи (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Файл хранится на ваших релеях. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="follow_list_kind3follows">Все подписки</string>
|
||||
<string name="follow_list_global">Глобально</string>
|
||||
<string name="upload_server_nostrbuild">nostr.build – доверенный</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build может изменять файл</string>
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev – доверенный</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev может изменять файл</string>
|
||||
<string name="upload_server_nostrbuild_nip94">Верифицируемый Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Проверка на изменение файла Nostr.build. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94">Верифицируемый Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Проверка на изменение файла nostrfiles.dev. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="app_notification_dms_channel_name">Личные сообщения</string>
|
||||
<string name="app_notification_dms_channel_description">Уведомления о входящих сообщениях</string>
|
||||
<string name="app_notification_zaps_channel_name">Запы</string>
|
||||
<string name="app_notification_zaps_channel_description">Уведомления о полученных запах</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sat</string>
|
||||
<string name="app_notification_zaps_channel_message_from">От %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">за %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,408 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name_release" translatable="false">Amethyst</string>
|
||||
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
|
||||
<string name="point_to_the_qr_code">Point to the QR Code</string>
|
||||
<string name="show_qr">Visa QR</string>
|
||||
<string name="profile_image">Profilbild</string>
|
||||
<string name="scan_qr">Scanna QR</string>
|
||||
<string name=”show_anyway">Visa ändå</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">Inlägget flaggades som olämpligt av</string>
|
||||
<string name=”post_not_found">Inlägg hittades inte</string>
|
||||
<string name=”channel_image">Kanal bild</string>
|
||||
<string name="referenced_event_not_found">Refererad händelse hittades inte</string>
|
||||
<string name="could_not_decrypt_the_message">Kunde inte dekryptera meddelandet</string>
|
||||
<string name=”group_picture">Grupp bild</string>
|
||||
<string name="explicit_content">Explicit Innehåll</string>
|
||||
<string name="spam">Spam</string>
|
||||
<string name="impersonation">Imitation</string>
|
||||
<string name="illegal_behavior">Olagligt beteende</string>
|
||||
<string name="unknown">Okänd</string>
|
||||
<string name=”relay_icon">Relä ikon</string>
|
||||
<string name="unknown_author">Okänd användare</string>
|
||||
<string name=”copy_text">Kopiera Text</string>
|
||||
<string name=”copy_user_pubkey">Kopiera användarens ID</string>
|
||||
<string name=”copy_note_id">Kopiera anteckningens ID</string>
|
||||
<string name=”broadcast">Sänd ut</string>
|
||||
<string name="request_deletion">Begär radering</string>
|
||||
<string name=”block_hide_user"><![CDATA[Blockera och göm användare]]></string>
|
||||
<string name="report_spam_scam">Rapportera Spam / Scam</string>
|
||||
<string name="report_impersonation">Rapportera identitetsstöld</string>
|
||||
<string name="report_explicit_content">Rapportera explicit innehåll</string>
|
||||
<string name="report_illegal_behaviour">Rapportera olagligt beteende</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">Logga in med en privat nyckel för att kunna svara</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Logga in med en privat nyckel för att kunna boosta inlägg</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">Logga in med en privat nyckel för att gilla inlägg</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">Ingen konfiguration av Zap-belopp. Tryck länge för att ändra</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">Logga in med en privat nyckel för att kunna skicka Zaps</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Antal visningar</string>
|
||||
<string name="boost">Boost</string>
|
||||
<string name="boosted">boosted</string>
|
||||
<string name="quote">Citera</string>
|
||||
<string name="new_amount_in_sats">Nytt belopp i Sats</string>
|
||||
<string name=”add">Lägg till</string>
|
||||
<string name=”replying_to">"Svarar till "</string>
|
||||
<string name="and">" och "</string>
|
||||
<string name="in_channel">"i kanalen "</string>
|
||||
<string name="profile_banner">Profil Banner</string>
|
||||
<string name="following">" Följer"</string>
|
||||
<string name="followers">" Följare"</string>
|
||||
<string name="profile">Profil</string>
|
||||
<string name="security_filters">Säkerhets Filter</string>
|
||||
<string name=”log_out">Logga ut</string>
|
||||
<string name=”show_more">Visa mer</string>
|
||||
<string name="lightning_invoice">Lightning Faktura</string>
|
||||
<string name="pay">Betala</string>
|
||||
<string name="lightning_tips">Lightning Dricks</string>
|
||||
<string name="note_to_receiver">Notering till mottagaren</string>
|
||||
<string name=”thank_you_so_much">Tack så mycket!</string>
|
||||
<string name="amount_in_sats">Summa i Sats</string>
|
||||
<string name="send_sats">Skicka Sats</string>
|
||||
<string name="error_parsing_preview_for">"Fel vid analys av förhandsvisning för %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Förhandsgranska kortbild för %1$s"</string>
|
||||
<string name=”new_channel">Ny kanal</string>
|
||||
<string name=”channel_name">Kanal Namn</string>
|
||||
<string name="my_awesome_group">Min fantastiska grupp</string>
|
||||
<string name="picture_url">Bild Url</string>
|
||||
<string name="description">Beskrivning</string>
|
||||
<string name=”about_us">"Om oss.. "</string>
|
||||
<string name=”what_s_on_your_mind">Vad tänker du på?</string>
|
||||
<string name="post">Dela</string>
|
||||
<string name="save">Spara</string>
|
||||
<string name="create">Skapa</string>
|
||||
<string name="cancel">Avbryt</string>
|
||||
<string name="failed_to_upload_the_image">Det gick inte att ladda upp bilden</string>
|
||||
<string name="relay_address">Relä Adress</string>
|
||||
<string name="posts">Inlägg</string>
|
||||
<string name="bytes">Bytes</string>
|
||||
<string name="errors">Errors</string>
|
||||
<string name=”home_feed">Hem Flöde</string>
|
||||
<string name=”private_message_feed">Privata Meddelande Flöde</string>
|
||||
<string name="public_chat_feed">Publik Chat Flöde</string>
|
||||
<string name="global_feed">Globalt Flöde</string>
|
||||
<string name="search_feed">Sök i Flödet</string>
|
||||
<string name=”add_a_relay">Lägg till Relä</string>
|
||||
<string name="display_name">Visningsnamn</string>
|
||||
<string name=”my_display_name">Mitt visningsnamn</string>
|
||||
<string name="username">Användarnamn</string>
|
||||
<string name=”my_username">Mitt användarnamn</string>
|
||||
<string name=”about_me">Om mig</string>
|
||||
<string name="avatar_url">Avatar URL</string>
|
||||
<string name="banner_url">Banner URL</string>
|
||||
<string name="website_url">Websida URL</string>
|
||||
<string name="ln_address">LN Adress</string>
|
||||
<string name="ln_url_outdated">LN URL (Utdaterad)</string>
|
||||
<string name=”image_saved_to_the_gallery">Bild sparad till galleriet</string>
|
||||
<string name=”failed_to_save_the_image">Misslyckades att spara bilden</string>
|
||||
<string name=”upload_image">Ladda upp bilden</string>
|
||||
<string name=”uploading">Laddar upp…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren har inte en Lightningadressinställning för att ta emot sats</string>
|
||||
<string name=”reply_here">"svara här.. "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopierar antecknings-ID till urklipp för delning</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Kopiera kanal-ID (anteckningen) till Urklipp</string>
|
||||
<string name="edits_the_channel_metadata">Redigerar kanalmetadata</string>
|
||||
<string name=”join">Gå med</string>
|
||||
<string name="known">Känd</string>
|
||||
<string name="new_requests">Nya förfrågningar</string>
|
||||
<string name="blocked_users">Blockerade användare</string>
|
||||
<string name="new_threads">Nya trådar</string>
|
||||
<string name="conversations">konversationer</string>
|
||||
<string name="notes">Anteckningar</string>
|
||||
<string name="replies">Svar</string>
|
||||
<string name="follows">"Följer"</string>
|
||||
<string name="reports">"Rapporter"</string>
|
||||
<string name="more_options">Fler alternativ</string>
|
||||
<string name="relays">" Reläer"</string>
|
||||
<string name="website">Websida</string>
|
||||
<string name="lightning_address">Lightning Adress</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering</string>
|
||||
<string name="copy_private_key_to_the_clipboard">Kopiera hemlig nyckel till urklipp</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">Kopierar den publika nyckeln till urklipp för delning</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">Kopiera Public Key (NPub) till Urklipp</string>
|
||||
<string name=”send_a_direct_message">Skicka ett direkt meddelande</string>
|
||||
<string name="edits_the_user_s_metadata">Redigerar användarens metadata</string>
|
||||
<string name="follow">Följ</string>
|
||||
<string name=”follow_back">Följ tillbaka</string>
|
||||
<string name=”unblock”>Sluta blockera</string>
|
||||
<string name="copy_user_id">Kopiera Användarens ID</string>
|
||||
<string name=”unblock_user”>Sluta blockera Användare</string>
|
||||
<string name="npub_hex_username">"npub, hex, användarnamn "</string>
|
||||
<string name="clear">Rensa</string>
|
||||
<string name="app_logo">App Logo</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec / npub / hex privat nyckel</string>
|
||||
<string name=”show_password">Visa lösenord</string>
|
||||
<string name=”hide_password">Göm lösenord</string>
|
||||
<string name="invalid_key">Ogiltig nyckel</string>
|
||||
<string name=”i_accept_the">"Jag accepterar "</string>
|
||||
<string name="terms_of_use">villkor</string>
|
||||
<string name="acceptance_of_terms_is_required">Godkännande av villkor krävs</string>
|
||||
<string name="key_is_required">Nyckel krävs</string>
|
||||
<string name=”login">Inloggning</string>
|
||||
<string name="generate_a_new_key">Skapa en ny nyckel</string>
|
||||
<string name=”loading_feed">Ladda flöde</string>
|
||||
<string name="error_loading_replies">"Det gick inte att läsa in svar: "</string>
|
||||
<string name=”try_again">Försök igen</string>
|
||||
<string name=”feed_is_empty">Flöde är tomt.</string>
|
||||
<string name="refresh">Uppdatera</string>
|
||||
<string name="created">Skapad</string>
|
||||
<string name="with_description_of">med beskrivning av</string>
|
||||
<string name=”and_picture">och bild</string>
|
||||
<string name="changed_chat_name_to">ändra chat namn till</string>
|
||||
<string name="description_to">beskrivning till</string>
|
||||
<string name="and_picture_to">och bild till</string>
|
||||
<string name="leave">Lämna</string>
|
||||
<string name=”unfollow">Sluta följa</string>
|
||||
<string name=”channel_created">Kanal skapad</string>
|
||||
<string name="channel_information_changed_to">"Kanalinformation ändrad till"</string>
|
||||
<string name="public_chat">Publik Chat</string>
|
||||
<string name="posts_received">mottagna inlägg</string>
|
||||
<string name=”remove">Ta bort</string>
|
||||
<string name="sats" translatable="false">sats</string>
|
||||
<string name="translations_auto">Auto</string>
|
||||
<string name=”translations_translated_from">översatt från</string>
|
||||
<string name="translations_to">till</string>
|
||||
<string name=”translations_show_in_lang_first">Visa i %1$s first</string>
|
||||
<string name=”translations_always_translate_to_lang">Översätt alltid till %1$s</string>
|
||||
<string name=”translations_never_translate_from_lang">Översätt aldrig från %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">aldrig</string>
|
||||
<string name="now">nu</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">Naket</string>
|
||||
<string name="profanity_hateful_speech">Svordomar / Hatiskt tal</string>
|
||||
<string name="report_hateful_speech">Anmäl hattal</string>
|
||||
<string name="report_nudity_porn">Rapportera nakenhet/porr</string>
|
||||
<string name="others">andras</string>
|
||||
<string name="mark_all_known_as_read">Markera alla kända som lästa</string>
|
||||
<string name="mark_all_new_as_read">Markera alla nya som lästa</string>
|
||||
<string name="mark_all_as_read">Markera allt som läst</string>
|
||||
<string name="backup_keys">Backup-nycklar</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">
|
||||
## Nyckelsäkerhetskopiering och säkerhetstips
|
||||
\n\nDitt konto är skyddat av en hemlig nyckel. Nyckeln är en lång slumpmässig sträng som börjar med **nsec1**. Alla som har tillgång till din hemliga nyckel kan publicera innehåll med din identitet.
|
||||
\n\n- Lägg **inte** in din hemliga nyckel på någon webbplats eller programvara som du inte litar på.
|
||||
\n- Amethyst-utvecklare kommer **aldrig** att be dig om din hemliga nyckel.
|
||||
\n- **Behåll en säker säkerhetskopia av din hemliga nyckel för kontoåterställning. Vi rekommenderar att du använder en lösenordshanterare. </string>
|
||||
<string name="secret_key_copied_to_clipboard">Hemlig nyckel (nsec) kopierad till urklipp</string>
|
||||
<string name="copy_my_secret_key">Kopiera min hemliga nyckel</string>
|
||||
<string name="biometric_authentication_failed">Autentisering misslyckades</string>
|
||||
<string name="biometric_error">Error</string>
|
||||
<string name=”badge_created_by">"Skapad av %1$s"</string>
|
||||
<string name=”badge_award_image_for”>"Tilldelad Badge för %1$s"</string>
|
||||
<string name=”new_badge_award_notif">Du har blivit tilldelad en ny Badge </string>
|
||||
<string name="award_granted_to">Badge utmärkelse tilldelas</string>
|
||||
<string name="copied_note_text_to_clipboard">Kopierade anteckningstext till urklipp</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Kopierade författarens @npub till urklipp</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Kopierade antecknings-ID (@note1) till urklipp</string>
|
||||
<string name="select_text_dialog_top">Välj Text</string>
|
||||
<string name="github" translatable="false">Github Gist med bevis</string>
|
||||
<string name="telegram" translatable="false">Telegram</string>
|
||||
<string name="mastodon" translatable="false">Mastodon antecknings ID med bevis</string>
|
||||
<string name="twitter" translatable="false">Twitter Status med bevis</string>
|
||||
<string name="github_proof_url_template" translatable="false">https://gist.github.com/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Kan inte dekryptera privat meddelande>\n\nDu citerades i en privat/krypterad konversation mellan %1$s och %2$s."</string>
|
||||
<string name=”account_switch_add_account_dialog_title">Lägg till nytt konto</string>
|
||||
<string name="drawer_accounts">Konton</string>
|
||||
<string name=”account_switch_select_account">Välj konto</string>
|
||||
<string name=”account_switch_add_account_btn">Lägg till nytt konto</string>
|
||||
<string name=”account_switch_active_account">Aktivt konto</string>
|
||||
<string name=”account_switch_has_private_key">Har privat nyckel</string>
|
||||
<string name=”account_switch_pubkey_only">Läs bara, ingen privat nyckel</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="quick_action_select">Välj</string>
|
||||
<string name="quick_action_share_browser_link">Dela webbläsarlänk</string>
|
||||
<string name="quick_action_share">Dela</string>
|
||||
<string name="quick_action_copy_user_id">Användar ID</string>
|
||||
<string name="quick_action_copy_note_id">Antecknings ID</string>
|
||||
<string name="quick_action_copy_text">Kopiera Text</string>
|
||||
<string name="quick_action_delete">Radera</string>
|
||||
<string name=”quick_action_unfollow">Sluta följa</string>
|
||||
<string name="quick_action_follow">Följ</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Begär radering</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst kommer att begära att din anteckning tas bort från de reläer du för närvarande är ansluten till. Det finns ingen garanti för att din anteckning kommer att raderas permanent från dessa reläer eller från andra reläer där den kan lagras.</string>
|
||||
<string name="quick_action_block_dialog_btn">Blockera</string>
|
||||
<string name="quick_action_delete_dialog_btn">Radera</string>
|
||||
<string name="quick_action_block">Blockera</string>
|
||||
<string name="quick_action_report">Rapportera</string>
|
||||
<string name="quick_action_delete_button">Radera</string>
|
||||
<string name=”quick_action_dont_show_again_button”>Visa inte igen</string>
|
||||
<string name="report_dialog_spam">Spam eller scams</string>
|
||||
<string name="report_dialog_profanity">Svordomar eller hatiskt beteende</string>
|
||||
<string name="report_dialog_impersonation">Skadlig identitetsstöld</string>
|
||||
<string name="report_dialog_nudity">Nakenhet eller grafiskt innehåll</string>
|
||||
<string name="report_dialog_illegal">Olagligt beteende</string>
|
||||
<string name="report_dialog_blocking_a_user">Blockering av en användare kommer att dölja deras innehåll i din app. Dina anteckningar är fortfarande offentliga, inklusive för personer du blockerar. Blockerade användare listas på skärmen Säkerhetsfilter.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Blockera & Göm användare]]></string>
|
||||
<string name="report_dialog_report_btn">Rapportera missbruk</string>
|
||||
<string name="report_dialog_reminder_public">Alla rapporter som publiceras kommer att vara offentliga.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Ge eventuellt ytterligare sammanhang om din rapport...</string>
|
||||
<string name="report_dialog_additional_reason_label">Ytterligare sammanhang</string>
|
||||
<string name="report_dialog_select_reason_label">Anledning</string>
|
||||
<string name=”report_dialog_select_reason_placeholder">Välj anledning…</string>
|
||||
<string name=”report_dialog_post_report_btn">Skicka rapport</string>
|
||||
<string name=”report_dialog_title">Blockera och rapportera</string>
|
||||
<string name="block_only">Blockera</string>
|
||||
|
||||
<string name="bookmarks">Bokmärken</string>
|
||||
<string name=”private_bookmarks">Privata Bokmärken</string>
|
||||
<string name="public_bookmarks">Publika Bokmärken</string>
|
||||
<string name="add_to_private_bookmarks">Lägg till i Privata Bokmärken</string>
|
||||
<string name=”add_to_public_bookmarks">Lägg till i Publika Bokmärken</string>
|
||||
<string name="remove_from_private_bookmarks">Ta bort från Privata Bokmärken</string>
|
||||
<string name="remove_from_public_bookmarks">Ta bort från Publika Bokmärken</string>
|
||||
|
||||
<string name="wallet_connect_service">Wallet Connect Service</string>
|
||||
<string name="wallet_connect_service_explainer">Tillåter en Nostr Secret att betala zaps utan att lämna appen. Håll hemligheten säker och använd ett privat relä om möjligt</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
|
||||
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
|
||||
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
|
||||
<string name="wallet_connect_service_show_secret">Visa secret key</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex privat nyckel</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">Pantbelopp i Sats</string>
|
||||
<string name="post_poll">Dela Poll</string>
|
||||
<string name="poll_heading_required">Obligatoriska fält:</string>
|
||||
<string name="poll_zap_recipients">Zap mottagare</string>
|
||||
<string name="poll_primary_description">Beskrivning av primär omröstning…</string>
|
||||
<string name="poll_option_index">Alternativ %s</string>
|
||||
<string name="poll_option_description">Beskrivning av omröstningsalternativ</string>
|
||||
<string name="poll_heading_optional">Valfria fält:</string>
|
||||
<string name=”poll_zap_value_min">Minsta Zap</string>
|
||||
<string name=”poll_zap_value_max”>Maximal Zap</string>
|
||||
<string name="poll_consensus_threshold">Konsensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name=”poll_closing_time">Avsluta efter</string>
|
||||
<string name="poll_closing_time_days">dagar</string>
|
||||
<string name="poll_is_closed">Omröstningen är stängd för nya röster</string>
|
||||
<string name="poll_zap_amount">Zap belopp</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Endast en röst per användare tillåts i denna typ av omröstning</string>
|
||||
|
||||
<string name=”looking_for_event">"Letar efter Event %1$s"</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">Lägg till ett offentligt meddelande</string>
|
||||
<string name="custom_zaps_add_a_message_private">Lägg till ett privat meddelande</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Lägg till ett fakturameddelande</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message_example">Tack för allt ditt arbete!</string>
|
||||
|
||||
<string name="lightning_create_and_add_invoice">Skapa och lägg till</string>
|
||||
<string name="poll_author_no_vote">Omröstningsskapare kan inte rösta i sina egna omröstningar.</string>
|
||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||
|
||||
<string name="hash_verification_passed">Bilden är densamma sedan inlägget</string>
|
||||
<string name="hash_verification_failed">Bilden har ändrats. Användaren kanske inte har sett förändringen</string>
|
||||
|
||||
<string name=”content_description_add_image">Lägg till Bild</string>
|
||||
<string name=”content_description_add_video">Lägg till Video</string>
|
||||
<string name=”content_description_add_document">Lägg till Dokument</string>
|
||||
|
||||
<string name=”add_content">Lägg till Meddelande</string>
|
||||
<string name="content_description">Beskrivning av innehållet</string>
|
||||
<string name="content_description_example">En blå båt på en vit sandstrand vid solnedgången</string>
|
||||
|
||||
<string name="zap_type">Zap Typ</string>
|
||||
<string name="zap_type_explainer">Zap Typ för alla alternativ</string>
|
||||
|
||||
<string name="zap_type_public">Publik</string>
|
||||
<string name="zap_type_public_explainer">Alla kan se transaktionen och meddelandet</string>
|
||||
|
||||
<string name="zap_type_private">Privat</string>
|
||||
<string name="zap_type_private_explainer">Avsändare och mottagare kan se varandra och läsa meddelandet</string>
|
||||
|
||||
<string name="zap_type_anonymous">Anonym</string>
|
||||
<string name="zap_type_anonymous_explainer">Mottagaren och allmänheten vet inte vem som skickade betalningen</string>
|
||||
|
||||
<string name=”zap_type_nonzap">Ingen Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Inga spår i Nostr, bara i Lightning</string>
|
||||
|
||||
|
||||
<string name="file_server">Fil Server</string>
|
||||
<string name="zap_forward_lnAddress">LnAdress or @Användare</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - betrodd</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - betrodd</string>
|
||||
<string name=”upload_server_nostrimg_explainer">NostrImg kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - betrodd</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan ändra filen</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - betrodd</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan ändra filen</string>
|
||||
|
||||
|
||||
<string name="upload_server_imgur_nip94">Verifierbar Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Kontrollerar om Imgur modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">Verifierbar NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Kontrollerar om NostrImg modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifierbar Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Kontrollerar om Nostr.build modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev_nip94">Verifierbar Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Kontrollerar om Nostrfiles.dev modifierat filen. Nytt NIP: andra klienter kanske inte ser det</string>
|
||||
|
||||
|
||||
<string name=”upload_server_relays_nip95">Dina Reläer (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder</string>
|
||||
|
||||
<string name="connect_via_tor_short">Tor/Orbot Inställningar</string>
|
||||
<string name="connect_via_tor">Anslut via dina Orbot-inställiningar</string>
|
||||
|
||||
<string name="do_you_really_want_to_disable_tor_title">Koppla från Orbot/Tor?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Dina data kommer omedelbart att överföras till det vanliga nätverket</string>
|
||||
<string name="yes">Ja</string>
|
||||
<string name="no">Nej</string>
|
||||
|
||||
|
||||
<string name="follow_list_selection">Följ lista</string>
|
||||
<string name="follow_list_kind3follows">Alla följare</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Anslut genom Tor med Orbot
|
||||
\n\n1. Installera [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Starta Orbot
|
||||
\n3. Kontrollera Socks-porten i Orbot. Standarden använder 9050
|
||||
\n4. Ändra vid behov porten i Orbot
|
||||
\n5. Konfigurera Socks-porten på den här skärmen
|
||||
\n6. Tryck på knappen Aktivera för att använda Orbot som proxy</string>
|
||||
<string name="orbot_socks_port">Orbot Socks Port</string>
|
||||
<string name="invalid_port_number">Ogiltigt portnummer</string>
|
||||
<string name="use_orbot">Använd Orbot</string>
|
||||
<string name=”disconnect_from_your_orbot_setup">Koppla ifrån Tor/Orbot</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">Standardkanal-ID</string>
|
||||
<string name="app_notification_private_message" translatable=”false">Ny notifikation har kommit</string>
|
||||
|
||||
<string name="app_notification_dms_channel_id" translatable="false">PrivataMeddelandID</string>
|
||||
<string name="app_notification_dms_channel_name">Privata Meddelade</string>
|
||||
<string name="app_notification_dms_channel_description">Meddelar dig när ett privat meddelande kommer</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
|
||||
<string name="app_notification_zaps_channel_name">Zaps Mottaget</string>
|
||||
<string name="app_notification_zaps_channel_description">Meddela dig när någon zappar dig</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Från %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">till %1$s</string>
|
||||
|
||||
<string name="reply_notify">Meddela: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Gå med i konversation</string>
|
||||
<string name="channel_list_user_or_group_id">Användare eller grupp ID</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
|
||||
<string name="channel_list_create_channel">Skapa</string>
|
||||
<string name=”channel_list_join_channel">Gå med</string>
|
||||
</resources>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -234,4 +234,91 @@
|
||||
<string name="report_dialog_post_report_btn">Відправити скаргу</string>
|
||||
<string name="private_conversation_notification">"<Не вдалося розшифрувати повідомлення>\n\n\пВас згадали в приватному/зашифрованому чаті між %1$s і %2$s."</string>
|
||||
<string name="report_dialog_title">Заблокувати та поскаржитися</string>
|
||||
<string name="bytes">Байт</string>
|
||||
<string name="follow_back">Підписатися у відповідь</string>
|
||||
<string name="bookmarks">Закладки</string>
|
||||
<string name="private_bookmarks">Особисті закладки</string>
|
||||
<string name="public_bookmarks">Публічні закладки</string>
|
||||
<string name="add_to_private_bookmarks">Додати в Особисті закладки</string>
|
||||
<string name="add_to_public_bookmarks">Додати в Публічні закладки</string>
|
||||
<string name="remove_from_private_bookmarks">Прибрати з Особистих закладок</string>
|
||||
<string name="remove_from_public_bookmarks">Прибрати з Публічних закладок</string>
|
||||
<string name="wallet_connect_service">Служба Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Дозволяє оплачувати запи за допомогою секрету, не виходячи з програми. Зберігайте секрет у безпеці та за можливості використовуйте приватний релей</string>
|
||||
<string name="wallet_connect_service_pubkey">Публічний ключ Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Релей Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Секрет Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Показати секрет</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / приватний ключ в hex</string>
|
||||
<string name="pledge_amount_in_sats">Сума внеску в sat</string>
|
||||
<string name="post_poll">Відправити опитування</string>
|
||||
<string name="poll_heading_required">Обов\'язкові поля:</string>
|
||||
<string name="poll_zap_recipients">Одержувачі запів</string>
|
||||
<string name="poll_primary_description">Опис опитування</string>
|
||||
<string name="poll_option_index">Варіант %s</string>
|
||||
<string name="poll_option_description">Опис варіанту</string>
|
||||
<string name="poll_heading_optional">Опціональні поля:</string>
|
||||
<string name="poll_zap_value_min">Мінімальний зап</string>
|
||||
<string name="poll_zap_value_max">Максимальний зап</string>
|
||||
<string name="poll_consensus_threshold">Консенсус</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Закриття після</string>
|
||||
<string name="poll_closing_time_days">днів</string>
|
||||
<string name="poll_is_closed">Опитування закрито</string>
|
||||
<string name="poll_zap_amount">Сума запу</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">У цьому опитуванні можна голосувати тільки один раз</string>
|
||||
<string name="looking_for_event">Пошук події %1$s</string>
|
||||
<string name="custom_zaps_add_a_message">Додати публічне повідомлення</string>
|
||||
<string name="custom_zaps_add_a_message_private">Додати приватне повідомлення</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Додати примітку до інвойсу</string>
|
||||
<string name="custom_zaps_add_a_message_example">Дякую всім вам за роботу!</string>
|
||||
<string name="lightning_create_and_add_invoice">Створити та Додати</string>
|
||||
<string name="poll_author_no_vote">Автор опитування не може голосувати</string>
|
||||
<string name="hash_verification_passed">Фото не змінилося після публікації</string>
|
||||
<string name="hash_verification_failed">Фото змінено. Автор міг не помітити зміну</string>
|
||||
<string name="content_description_add_image">Додати фото</string>
|
||||
<string name="content_description_add_video">Додати відео</string>
|
||||
<string name="content_description_add_document">Додати документ</string>
|
||||
<string name="add_content">Додати до повідомлення</string>
|
||||
<string name="content_description">Опис вмісту</string>
|
||||
<string name="content_description_example">Компанія веселих молодих людей</string>
|
||||
<string name="zap_type">Тип запу</string>
|
||||
<string name="zap_type_explainer">Тип запу для всіх варіантів</string>
|
||||
<string name="zap_type_public">Публічний</string>
|
||||
<string name="zap_type_public_explainer">Всі бачать платіж і повідомлення</string>
|
||||
<string name="zap_type_private">Приватний</string>
|
||||
<string name="zap_type_private_explainer">Відправник і одержувач бачать один одного і можуть прочитати повідомлення</string>
|
||||
<string name="zap_type_anonymous">Анонімний</string>
|
||||
<string name="zap_type_anonymous_explainer">Ніхто не бачить відправника платежу</string>
|
||||
<string name="zap_type_nonzap">Не-зап</string>
|
||||
<string name="zap_type_nonzap_explainer">Без сліду в Nostr, звичайний Lightning платіж</string>
|
||||
<string name="file_server">Сервер для завантаження</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress або @User</string>
|
||||
<string name="upload_server_imgur">imgur.com – довірений</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur може змінювати файл</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com – довірений</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg може змінювати файл</string>
|
||||
<string name="upload_server_imgur_nip94">Верифікований Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Перевірка на зміну файлу Imgur. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_nostrimg_nip94">Верифікований NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Перевірка на зміну файлу NostrImg. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_relays_nip95">Ваші релеї (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Файл зберігається на ваших релеях. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="follow_list_kind3follows">Всі підписки</string>
|
||||
<string name="follow_list_global">Глобально</string>
|
||||
<string name="upload_server_nostrbuild">nostr.build – довірений</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build може змінювати файл</string>
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev – довірений</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev може змінювати файл</string>
|
||||
<string name="upload_server_nostrbuild_nip94">Верифікований Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Перевірка на зміну файлу Nostr.build. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94">Верифікований Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Перевірка на зміну файлу nostrfiles.dev. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="app_notification_dms_channel_name">Особисті повідомлення</string>
|
||||
<string name="app_notification_dms_channel_description">Сповіщення про вхідні повідомлення</string>
|
||||
<string name="app_notification_zaps_channel_name">Запи</string>
|
||||
<string name="app_notification_zaps_channel_description">Сповіщення про отримані запахи</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sat</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Від %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">за %1$s</string>
|
||||
</resources>
|
||||
@@ -296,7 +296,6 @@
|
||||
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||
|
||||
|
||||
<string name="hash_verification_passed">Image is the same since the post</string>
|
||||
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
|
||||
|
||||
@@ -356,10 +355,51 @@
|
||||
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
|
||||
|
||||
<string name="connect_via_tor_short">Tor/Orbot setup</string>
|
||||
<string name="connect_via_tor">Connect through your Orbot setup</string>
|
||||
|
||||
<string name="do_you_really_want_to_disable_tor_title">Disconnect from your Orbot/Tor?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Your data will be immediately transferred in the regular network</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
|
||||
|
||||
<string name="follow_list_selection">Follow List</string>
|
||||
<string name="follow_list_kind3follows">All Follows</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Connect through Tor with Orbot
|
||||
\n\n1. Install [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Start Orbot
|
||||
\n3. In Orbot, check the Socks port. The default uses 9050
|
||||
\n4. If necessary change the port in Orbot
|
||||
\n5. Configure the Socks port in this screen
|
||||
\n6. Press the Activate button to use Orbot as a proxy
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks Port</string>
|
||||
<string name="invalid_port_number">Invalid port number</string>
|
||||
<string name="use_orbot">Use Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Disconnect Tor/Orbot</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
|
||||
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
|
||||
|
||||
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
|
||||
<string name="app_notification_dms_channel_name">Private Messages</string>
|
||||
<string name="app_notification_dms_channel_description">Notifies you when a private message arrives</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
|
||||
<string name="app_notification_zaps_channel_name">Zaps Received</string>
|
||||
<string name="app_notification_zaps_channel_description">Notifies you when somebody zaps you</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
|
||||
|
||||
<string name="reply_notify">Notify: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Join Conversation</string>
|
||||
<string name="channel_list_user_or_group_id">User or Group\'s ID</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
|
||||
<string name="channel_list_create_channel">Create</string>
|
||||
<string name="channel_list_join_channel">Join</string>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".Amethyst">
|
||||
|
||||
<service
|
||||
android:name=".service.notifications.PushNotificationReceiverService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
android:resource="@drawable/amethyst" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_color"
|
||||
android:resource="@color/purple_500" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="@string/app_notification_channel_id" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user