mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aadf8a883 | ||
|
|
bb9fa8e144 | ||
|
|
5a3cf40402 | ||
|
|
1519e70b24 | ||
|
|
135596591a | ||
|
|
389afcb600 | ||
|
|
d6c2966792 | ||
|
|
dd8b208a0d | ||
|
|
27182a77a4 | ||
|
|
cf5f345d54 | ||
|
|
3950517743 | ||
|
|
7a14a1ab26 | ||
|
|
9e93a35ee7 | ||
|
|
76df70c6f6 | ||
|
|
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 | ||
|
|
4325975686 | ||
|
|
53ec9d777f | ||
|
|
3e6e7d4863 | ||
|
|
81290f2b26 | ||
|
|
4dcf38c492 | ||
|
|
04c1300317 | ||
|
|
0d051117b2 | ||
|
|
d31eca1b42 | ||
|
|
a503a1b218 | ||
|
|
70890def82 | ||
|
|
d0eadcf30d | ||
|
|
ae268cc197 | ||
|
|
0bf317a818 | ||
|
|
915f7c07e8 | ||
|
|
69a1302404 | ||
|
|
d6d273e34c | ||
|
|
462c73341f | ||
|
|
aa07fbfc94 | ||
|
|
59d7f7cc70 | ||
|
|
950eefa4e3 | ||
|
|
f2bbdd44a6 | ||
|
|
cb09a26ef5 | ||
|
|
43156e3e11 | ||
|
|
faf215110a | ||
|
|
839e00ae36 | ||
|
|
b829a42d76 | ||
|
|
e1c3191f31 | ||
|
|
dd62d2bae4 | ||
|
|
3c3b6c97c0 | ||
|
|
dabeea43ea | ||
|
|
28521c1a60 | ||
|
|
9c30547bd7 | ||
|
|
b07fd253c7 | ||
|
|
264d6a40b3 | ||
|
|
20a65a0878 | ||
|
|
6285bfaae7 | ||
|
|
77a1b2ba89 | ||
|
|
3217d5095b | ||
|
|
5d9d7ba16e | ||
|
|
c648f54b51 | ||
|
|
4f8d33d850 | ||
|
|
9f3d6a75e6 | ||
|
|
ad420da6de | ||
|
|
a354fe928b | ||
|
|
cf8747b19f | ||
|
|
2ae54b544f | ||
|
|
bae1b6c6e6 | ||
|
|
ad2eabf386 |
@@ -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.
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -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
|
||||
|
||||
@@ -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 148
|
||||
versionName "0.43.0"
|
||||
versionCode 167
|
||||
versionName "0.49.4"
|
||||
|
||||
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'
|
||||
|
||||
|
||||
68
app/google-services.json
Normal file
68
app/google-services.json
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
@@ -21,12 +21,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -39,18 +39,17 @@ class ImageUploadTesting {
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -68,12 +67,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -88,4 +87,27 @@ class ImageUploadTesting {
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrFilesDevServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -50,10 +51,13 @@ private object PrefKeys {
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
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" }
|
||||
}
|
||||
|
||||
@@ -203,15 +207,24 @@ object LocalPreferences {
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_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()
|
||||
@@ -225,6 +238,7 @@ object LocalPreferences {
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
@@ -274,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()),
|
||||
@@ -288,10 +305,13 @@ object LocalPreferences {
|
||||
defaultFileServer,
|
||||
defaultHomeFollowList,
|
||||
defaultStoriesFollowList,
|
||||
defaultNotificationFollowList,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
latestContactList,
|
||||
proxy,
|
||||
proxyPort
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -38,19 +38,15 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
|
||||
@@ -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(
|
||||
@@ -55,10 +56,13 @@ class Account(
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
|
||||
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()
|
||||
|
||||
@@ -751,6 +755,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
defaultNotificationFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -867,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
|
||||
}
|
||||
}
|
||||
@@ -929,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.
|
||||
@@ -939,7 +954,8 @@ class Account(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes
|
||||
Constants.forcedRelayForSearch.feedTypes,
|
||||
proxy
|
||||
)
|
||||
}
|
||||
|
||||
@@ -948,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()
|
||||
}
|
||||
|
||||
@@ -1030,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) {
|
||||
@@ -1064,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)) {
|
||||
|
||||
@@ -55,19 +55,15 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter)
|
||||
|
||||
@@ -57,7 +57,7 @@ class Channel(val idHex: String) {
|
||||
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
|
||||
val important = notes.values
|
||||
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
.take(1000)
|
||||
.toSet()
|
||||
@@ -74,9 +74,9 @@ class Channel(val idHex: String) {
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
|
||||
@@ -50,6 +50,23 @@ fun decodePublicKey(key: String): ByteArray {
|
||||
}
|
||||
}
|
||||
|
||||
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
|
||||
return try {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex
|
||||
|
||||
if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
|
||||
} else if (pubKeyParsed != null) {
|
||||
pubKeyParsed
|
||||
} else {
|
||||
Hex.decode(key).toHexKey()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
|
||||
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.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 +281,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 +372,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 +382,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 +399,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -736,20 +763,23 @@ object LocalCache {
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
val file = File(cachePath, event.id)
|
||||
if (!file.exists()) {
|
||||
val stream = FileOutputStream(file)
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
@@ -804,6 +834,12 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null && users[key] != null) {
|
||||
return listOfNotNull(users[key])
|
||||
}
|
||||
|
||||
return users.values.filter {
|
||||
(it.anyNameStartsWith(username)) ||
|
||||
it.pubkeyHex.startsWith(username, true) ||
|
||||
@@ -812,6 +848,16 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (key != null && (notes[key] ?: addressables[key]) != null) {
|
||||
return listOfNotNull(notes[key] ?: addressables[key])
|
||||
}
|
||||
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
|
||||
@@ -827,6 +873,16 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (key != null && channels[key] != null) {
|
||||
return listOfNotNull(channels[key])
|
||||
}
|
||||
|
||||
return channels.values.filter {
|
||||
it.anyNameStartsWith(text) ||
|
||||
it.idHex.startsWith(text, true) ||
|
||||
@@ -907,18 +963,74 @@ object LocalCache {
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
fun consume(event: Event, relay: Relay?) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||
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)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
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 {
|
||||
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 ->
|
||||
_newEventBundles.emit(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,20 +410,16 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (note is AddressableNote) {
|
||||
|
||||
@@ -396,9 +396,9 @@ class UserMetadata {
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,10 +406,6 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -19,11 +19,15 @@ class FileHeader(
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -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("@")
|
||||
|
||||
@@ -75,7 +75,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -96,12 +96,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -15,13 +15,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val chatRoomList = "ChatroomList"
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -39,7 +40,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,7 +49,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -72,7 +73,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
@@ -80,7 +81,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -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,10 +58,10 @@ 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()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -72,20 +74,20 @@ 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())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var searchString: String? = null
|
||||
@@ -19,15 +19,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
|
||||
decodePublicKey(mySearchString).toHex()
|
||||
} else if (mySearchString.startsWith("note")) {
|
||||
mySearchString.bechToBytes().toHex()
|
||||
} else {
|
||||
mySearchString
|
||||
}
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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("@")
|
||||
|
||||
@@ -4,7 +4,7 @@ import java.util.regex.Pattern
|
||||
|
||||
object LnWithdrawalUtil {
|
||||
private val withdrawalPattern = Pattern.compile(
|
||||
"lnurl.*",
|
||||
"lnurl.+",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,28 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
|
||||
}
|
||||
}
|
||||
|
||||
class EOSEAccount(var users: Map<User, EOSERelayList> = emptyMap()) {
|
||||
fun addOrUpdate(user: User, relayUrl: String, time: Long) {
|
||||
val relayList = users[user]
|
||||
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
|
||||
fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
|
||||
val relayList = followList[listCode]
|
||||
if (relayList == null) {
|
||||
users = users + mapOf(user to EOSERelayList(mapOf(relayUrl to EOSETime(time))))
|
||||
val newList = EOSERelayList()
|
||||
newList.addOrUpdate(relayUrl, time)
|
||||
followList = followList + mapOf(listCode to newList)
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
|
||||
fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) {
|
||||
val followList = users[user]
|
||||
if (followList == null) {
|
||||
val newList = EOSEFollowList()
|
||||
newList.addOrUpdate(listCode, relayUrl, time)
|
||||
users = users + mapOf(user to newList)
|
||||
} else {
|
||||
followList.addOrUpdate(listCode, relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData = try {
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc = url.openConnection() as HttpURLConnection
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
if (responseCode in 300..400) {
|
||||
val newUrl: String = huc.getHeaderField("Location")
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc = url.openConnection() as HttpURLConnection
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
if (responseCode in 200..300) {
|
||||
huc.inputStream.use { it.readBytes() }
|
||||
} else {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
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 okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -27,79 +23,75 @@ object ImageUploader {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
server: ServersAvailable,
|
||||
context: Context,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
val myServer = if (server == ServersAvailable.IMGUR) {
|
||||
ImgurServer()
|
||||
} else if (server == ServersAvailable.NOSTRIMG) {
|
||||
NostrImgServer()
|
||||
} else if (server == ServersAvailable.NOSTR_BUILD) {
|
||||
NostrBuildServer()
|
||||
} else {
|
||||
ImgurServer()
|
||||
val myServer = when (server) {
|
||||
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
ImgurServer()
|
||||
}
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
else -> {
|
||||
ImgurServer()
|
||||
}
|
||||
}
|
||||
|
||||
val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri
|
||||
if (file != null) {
|
||||
uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
|
||||
}
|
||||
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
file: File,
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
serverType: ServersAvailable,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
|
||||
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()
|
||||
|
||||
if (serverType == ServersAvailable.NOSTR_BUILD) {
|
||||
requestBuilder.addHeader("Content-Type", "multipart/form-data")
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"fileToUpload",
|
||||
"$fileName.$extension",
|
||||
file.asRequestBody(contentType?.toMediaType())
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
server.inputParameterName(contentType),
|
||||
"$fileName.$extension",
|
||||
|
||||
)
|
||||
.build()
|
||||
} else {
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
category,
|
||||
"$fileName.$extension",
|
||||
object : RequestBody() {
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
}
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
)
|
||||
.build()
|
||||
|
||||
server.clientID()?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
@@ -116,7 +108,7 @@ object ImageUploader {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSucess(body.string())
|
||||
val url = server.parseUrlFromSuccess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
@@ -137,41 +129,10 @@ object ImageUploader {
|
||||
}
|
||||
}
|
||||
|
||||
fun getRealPathFromURI(uri: Uri, context: Context): String? {
|
||||
val returnCursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
returnCursor.moveToFirst()
|
||||
val name = returnCursor.getString(nameIndex)
|
||||
val file = File(context.filesDir, name)
|
||||
try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val outputStream = FileOutputStream(file)
|
||||
var read = 0
|
||||
val maxBufferSize = 1 * 1024 * 1024
|
||||
val bytesAvailable: Int = inputStream?.available() ?: 0
|
||||
val bufferSize = Math.min(bytesAvailable, maxBufferSize)
|
||||
val buffers = ByteArray(bufferSize)
|
||||
while (inputStream?.read(buffers).also {
|
||||
if (it != null) {
|
||||
read = it
|
||||
}
|
||||
} != -1
|
||||
) {
|
||||
outputStream.write(buffers, 0, read)
|
||||
}
|
||||
Log.e("File Size", "Size " + file.length())
|
||||
inputStream?.close()
|
||||
outputStream.close()
|
||||
Log.e("File Path", "Path " + file.path)
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("Exception", e.message!!)
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSucess(body: String): String?
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(): String? = null
|
||||
}
|
||||
@@ -179,12 +140,16 @@ abstract class FileServer {
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -194,21 +159,43 @@ class ImgurServer : FileServer() {
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val url = jacksonObjectMapper().readTree(body) // return url.toString()
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "fileToUpload"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
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()
|
||||
searchBarViewModel.account = account
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URL
|
||||
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
@@ -46,6 +44,8 @@ open class NewMediaModel : ViewModel() {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ open class NewMediaModel : ViewModel() {
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
context = context,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
@@ -115,18 +114,12 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(15000)
|
||||
}
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
try {
|
||||
val imageData = URL(imageUrl).readBytes()
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
@@ -154,8 +147,8 @@ open class NewMediaModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
|
||||
@@ -109,6 +109,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
}
|
||||
}
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -117,6 +124,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
@@ -191,9 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
if (isNIP94Server(postViewModel.selectedServer) ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -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
|
||||
@@ -24,8 +25,10 @@ import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -47,6 +50,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 +62,9 @@ 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.service.NostrSearchEventOrUserDataSource
|
||||
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 +85,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)
|
||||
@@ -92,6 +97,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
@@ -145,10 +156,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 +228,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
postViewModel.imageUploadingError.emit(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -345,6 +359,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,
|
||||
@@ -588,9 +644,11 @@ enum class ServersAvailable {
|
||||
IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@@ -599,7 +657,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) ?: ""
|
||||
@@ -612,9 +671,11 @@ fun ImageVideoDescription(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
@@ -705,7 +766,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -742,9 +808,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
if (isNIP94Server(selectedServer) ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -14,12 +14,12 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -145,10 +145,9 @@ open class NewPostViewModel : ViewModel() {
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
server = server,
|
||||
context = context,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) {
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
@@ -190,6 +189,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestions = emptyList()
|
||||
userSuggestionAnchor = null
|
||||
userSuggestionsMainMessage = null
|
||||
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
|
||||
open fun findUrlInMessage(): String? {
|
||||
@@ -200,8 +201,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) {
|
||||
@@ -213,8 +214,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = true
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -227,8 +230,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionAnchor = it.selection
|
||||
userSuggestionsMainMessage = false
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -288,13 +293,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
|
||||
@@ -171,7 +171,6 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
server = account.defaultFileServer,
|
||||
context = context,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
onUploading(false)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -42,7 +43,7 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val whereToCut = remember {
|
||||
val whereToCut = remember(content) {
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
@@ -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(robotSize, 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(
|
||||
@@ -259,7 +265,6 @@ private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
println("UrlImageView")
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -272,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) {
|
||||
@@ -285,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,
|
||||
|
||||
@@ -19,7 +19,7 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ object BookmarkPublicFeedFilter : FeedFilter<Note>() {
|
||||
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
return channel.notes
|
||||
.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
|
||||
return messages.roomMessages
|
||||
.filter { myAccount.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,29 +9,30 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val privateChatrooms = me.privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
(it.pubkeyHex in followingKeySet || me.hasSentMessagesTo(it)) && !account.isHidden(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map { it ->
|
||||
it.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels)
|
||||
.filterNotNull()
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,23 @@ object ChatroomListNewFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = ChatroomListKnownFeedFilter.account.userProfile()
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet()
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
!me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
it.pubkeyHex !in followingKeySet && !me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
privateChatrooms[it]
|
||||
?.roomMessages
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return privateMessages
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -44,6 +44,6 @@ object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
@@ -40,6 +41,6 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -18,6 +19,10 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet()
|
||||
|
||||
val loggedInUser = account.userProfile()
|
||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||
|
||||
@@ -28,6 +33,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.author !== loggedInUser &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
||||
tagsAnEventByUser(it, loggedInUser)
|
||||
@@ -35,14 +41,22 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
|
||||
fun tagsAnEventByUser(note: Note, author: User): Boolean {
|
||||
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) {
|
||||
|
||||
@@ -19,7 +19,7 @@ object PeopleListFeedFilter : FeedFilter<Note>() {
|
||||
.toSet()
|
||||
|
||||
return lists
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
return (notes + addresses)
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
return user?.notes
|
||||
?.filter { account?.isAcceptable(it) == true && !it.isNewThread() }
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +17,13 @@ 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)
|
||||
?.filter { account?.isAcceptable(it) == true && it.isNewThread() }
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
return user?.reports
|
||||
?.values
|
||||
?.flatten()
|
||||
?.sortedBy { it.createdAt() }
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,38 +72,50 @@ 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.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(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultNotificationFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) {
|
||||
@@ -227,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,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,
|
||||
@@ -273,13 +331,13 @@ fun SimpleTextSpinner(
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
var currentText by remember { mutableStateOf(placeholder) }
|
||||
var currentText by remember(placeholder) { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(currentText)
|
||||
Text(placeholder)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -75,10 +75,12 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -31,6 +32,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -45,6 +47,8 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
@@ -65,12 +69,19 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -90,7 +101,9 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
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
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -55,9 +56,12 @@ import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.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
|
||||
@@ -65,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
@@ -80,328 +85,486 @@ fun ChatroomMessageCompose(
|
||||
navController: NavController,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
|
||||
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
val noteReportsState by baseNote.live().reports.observeAsState()
|
||||
val noteForReports = noteReportsState?.note ?: return
|
||||
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
|
||||
|
||||
val accountUser = account.userProfile()
|
||||
val noteEvent = note?.event
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
var showHiddenNote by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
|
||||
if (!account.isHidden(noteForReports.author!!)) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
Modifier,
|
||||
innerQuote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
if (noteEvent == null) {
|
||||
BlankNote(
|
||||
Modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
)
|
||||
|
||||
note?.let {
|
||||
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
var backgroundBubbleColor: Color
|
||||
var alignment: Arrangement.Horizontal
|
||||
var shape: Shape
|
||||
var showHiddenNote by remember { mutableStateOf(false) }
|
||||
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
account.userProfile().let { loggedIn ->
|
||||
val newCanPreview = note.author === loggedIn ||
|
||||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
|
||||
!(noteForReports.hasAnyReports())
|
||||
|
||||
if (note.author == accountUser) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
alignment = Arrangement.End
|
||||
shape = ChatBubbleShapeMe
|
||||
} else {
|
||||
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
|
||||
alignment = Arrangement.Start
|
||||
shape = ChatBubbleShapeThem
|
||||
}
|
||||
val newIsAcceptable = account.isAcceptable(noteForReports)
|
||||
|
||||
if (parentBackgroundColor != null) {
|
||||
backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor)
|
||||
} else {
|
||||
backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background)
|
||||
}
|
||||
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
isNew = createdAt > lastTime
|
||||
if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) {
|
||||
isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
val modif = if (innerQuote) {
|
||||
Modifier.padding(top = 10.dp, end = 5.dp)
|
||||
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
|
||||
if (!account.isHidden(noteForReports.author!!)) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
Modifier,
|
||||
innerQuote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val backgroundBubbleColor: Color
|
||||
val alignment: Arrangement.Horizontal
|
||||
val shape: Shape
|
||||
|
||||
if (note.author == loggedIn) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
|
||||
|
||||
alignment = Arrangement.End
|
||||
shape = ChatBubbleShapeMe
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
|
||||
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
|
||||
|
||||
alignment = Arrangement.Start
|
||||
shape = ChatBubbleShapeThem
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
val modif = remember {
|
||||
if (innerQuote) {
|
||||
Modifier.padding(top = 10.dp, end = 5.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize = it
|
||||
}
|
||||
modifier = modif,
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
availableBubbleSize = it
|
||||
}
|
||||
) {
|
||||
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize = it
|
||||
}
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
val authorState by note.author!!.live().metadata.observeAsState()
|
||||
val author = authorState?.user!!
|
||||
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = ResizeImage(author.profilePicture(), 25.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(25.dp)
|
||||
.height(25.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${author.toBestDisplayName()}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val replyTo = note.replyTo
|
||||
if (!innerQuote && !replyTo.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo.toSet().mapIndexed { _, note ->
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
innerQuote = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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
|
||||
?: ""
|
||||
) + "'"
|
||||
)
|
||||
} 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
|
||||
?: ""
|
||||
) + "'"
|
||||
)
|
||||
} else {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
val canPreview = note.author == accountUser ||
|
||||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
TranslatableRichTextViewer(
|
||||
eventContent,
|
||||
canPreview,
|
||||
Modifier.padding(top = 5.dp),
|
||||
note.event?.tags(),
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
TranslatableRichTextViewer(
|
||||
stringResource(R.string.could_not_decrypt_the_message),
|
||||
true,
|
||||
Modifier.padding(top = 5.dp),
|
||||
note.event?.tags(),
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.width.toDp(),
|
||||
availableBubbleSize.width.toDp()
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize = it
|
||||
}
|
||||
) {
|
||||
if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
|
||||
DrawAuthorInfo(
|
||||
baseNote,
|
||||
alignment,
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
val replyTo = note.replyTo
|
||||
if (!innerQuote && !replyTo.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo.toSet().mapIndexed { _, note ->
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
innerQuote = true,
|
||||
parentBackgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row() {
|
||||
Text(
|
||||
timeAgoShort(note.createdAt(), context),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
|
||||
RelayBadges(note)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Row() {
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
|
||||
onWantsToReply(baseNote)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
when (noteEvent) {
|
||||
is ChannelCreateEvent -> {
|
||||
RenderCreateChannelNote(note)
|
||||
}
|
||||
|
||||
is ChannelMetadataEvent -> {
|
||||
RenderChangeChannelMetadataNote(note)
|
||||
}
|
||||
|
||||
else -> {
|
||||
RenderRegularTextNote(
|
||||
note,
|
||||
loggedIn,
|
||||
isAcceptableAndCanPreview.second,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.width.toDp(),
|
||||
availableBubbleSize.width.toDp()
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
StatusRow(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
onWantsToReply
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
NoteQuickActionMenu(
|
||||
note,
|
||||
popupExpanded,
|
||||
{ popupExpanded = false },
|
||||
accountViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
|
||||
private fun StatusRow(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val time = remember { baseNote.createdAt() ?: 0 }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ChatTimeAgo(time)
|
||||
RelayBadges(baseNote)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
|
||||
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
FlowRow(Modifier.padding(start = 10.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
val url = it.removePrefix("wss://").removePrefix("ws://")
|
||||
Box(
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
robotSize = 15.dp,
|
||||
model = "https://$url/favicon.ico",
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteRelays.size > 3 && !expanded) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(15.dp)),
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatTimeAgo(time: Long) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var timeStr by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(key1 = time) {
|
||||
withContext(Dispatchers.IO) {
|
||||
timeStr = timeAgoShort(time, context = context)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
timeStr,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRegularTextNote(
|
||||
note: Note,
|
||||
loggedIn: User,
|
||||
canPreview: Boolean,
|
||||
backgroundBubbleColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val tags = remember { note.event?.tags() }
|
||||
val eventContent = remember { accountViewModel.decrypt(note) }
|
||||
val modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
|
||||
if (eventContent != null) {
|
||||
TranslatableRichTextViewer(
|
||||
eventContent,
|
||||
canPreview,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
TranslatableRichTextViewer(
|
||||
stringResource(R.string.could_not_decrypt_the_message),
|
||||
true,
|
||||
modifier,
|
||||
tags,
|
||||
backgroundBubbleColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderChangeChannelMetadataNote(
|
||||
note: Note
|
||||
) {
|
||||
val noteEvent = note.event as? ChannelMetadataEvent ?: return
|
||||
|
||||
val channelInfo = noteEvent.channelInfo()
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderCreateChannelNote(note: Note) {
|
||||
val noteEvent = note.event as? ChannelCreateEvent ?: return
|
||||
val channelInfo = remember { noteEvent.channelInfo() }
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawAuthorInfo(
|
||||
baseNote: Note,
|
||||
alignment: Arrangement.Horizontal,
|
||||
navController: NavController
|
||||
) {
|
||||
val userState by baseNote.author!!.live().metadata.observeAsState()
|
||||
|
||||
val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return
|
||||
val route = remember { "User/$pubkeyHex" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = pubkeyHex,
|
||||
model = userProfilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(25.dp)
|
||||
.height(25.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
navController.navigate(route)
|
||||
})
|
||||
)
|
||||
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = " $userDisplayName",
|
||||
suffix = "",
|
||||
tags = userTags,
|
||||
fontWeight = FontWeight.Bold,
|
||||
overrideColor = MaterialTheme.colors.onBackground,
|
||||
route = route,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class RelayBadgesState(
|
||||
val shouldDisplayExpandButton: Boolean,
|
||||
val noteRelays: List<String>,
|
||||
val noteRelaysSimple: List<String>
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
|
||||
val state: RelayBadgesState by remember(noteRelaysState) {
|
||||
val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3
|
||||
val noteRelays = noteRelaysState?.note?.relays?.toList() ?: emptyList()
|
||||
val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toList() ?: emptyList()
|
||||
|
||||
mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple))
|
||||
}
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val relaysToDisplay by remember {
|
||||
derivedStateOf {
|
||||
if (expanded) state.noteRelays else state.noteRelaysSimple
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(Modifier.padding(start = 10.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
RenderRelay(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.shouldDisplayExpandButton && !expanded) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(15.dp)),
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelay(dirtyUrl: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
val website = remember {
|
||||
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
|
||||
"https://$cleanUrl"
|
||||
}
|
||||
val iconUrl = remember {
|
||||
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
|
||||
"https://$cleanUrl/favicon.ico"
|
||||
}
|
||||
|
||||
val clickableModifier = remember {
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
.clickable(onClick = { uri.openUri(website) })
|
||||
}
|
||||
|
||||
val colorFilter = remember {
|
||||
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = clickableModifier
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = iconUrl,
|
||||
robotSize = 15.dp,
|
||||
model = iconUrl,
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = colorFilter,
|
||||
modifier = iconModifier.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -31,6 +32,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -44,6 +46,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
@@ -65,12 +68,19 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -90,7 +100,9 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,39 +67,40 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
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,110 +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 = {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
scope.launch {
|
||||
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>,
|
||||
@@ -195,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +263,7 @@ fun AuthorGalleryZaps(
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountUser: State<UserState?>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = zapRequest.author ?: return
|
||||
@@ -224,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())
|
||||
@@ -243,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(
|
||||
@@ -286,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user