mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d722cdb7 | ||
|
|
14bc1fb7d4 | ||
|
|
90955515d0 | ||
|
|
a22ec35a26 | ||
|
|
f38f5737b0 | ||
|
|
e958515dad | ||
|
|
add6b06446 | ||
|
|
d070712f2b | ||
|
|
b9f58b3701 | ||
|
|
91f3f036ae | ||
|
|
4ef90e26cf | ||
|
|
f7182d6393 | ||
|
|
99861facc9 | ||
|
|
a5728daeb0 | ||
|
|
a4ac2eeb89 | ||
|
|
a5a320b889 | ||
|
|
8f927c29e3 | ||
|
|
a739b27d14 | ||
|
|
2f2ca97ec1 | ||
|
|
114186ce06 | ||
|
|
8187a7f5a9 | ||
|
|
f8319c720a | ||
|
|
3d93fe3d18 | ||
|
|
b4373ae1ed | ||
|
|
a37effd42d | ||
|
|
655cd20a01 | ||
|
|
a13dbf2bda | ||
|
|
f1affc2dbb | ||
|
|
cbc0f95498 | ||
|
|
b3f8b06fea | ||
|
|
ed4c19ac79 | ||
|
|
37bd7e34a0 | ||
|
|
c1d05f8b2f | ||
|
|
9c715dd381 | ||
|
|
a720a636d3 | ||
|
|
5322d6e900 | ||
|
|
9d849f12c0 | ||
|
|
18dc5354bd | ||
|
|
a013f1f1cc | ||
|
|
e1d236d9cf | ||
|
|
12a2a21c0e | ||
|
|
9ae13a9f97 | ||
|
|
bd64a7de63 | ||
|
|
12a3cd5113 | ||
|
|
1d23e87bd9 | ||
|
|
b23bea8971 | ||
|
|
830ade9ad6 | ||
|
|
ed07248f7d | ||
|
|
9aa5993748 | ||
|
|
e7909beaab | ||
|
|
79e3450a46 | ||
|
|
783204b57f | ||
|
|
1adb2b2caa | ||
|
|
3f579793ca | ||
|
|
8040608e14 | ||
|
|
2d202e2244 | ||
|
|
2e66d278ad | ||
|
|
1bfbf3643d | ||
|
|
5990bf0213 | ||
|
|
5e3db99675 | ||
|
|
a291741c37 | ||
|
|
94ee80d8ea | ||
|
|
f7de66c6a5 | ||
|
|
48d890b354 | ||
|
|
c7a9bd1226 | ||
|
|
c40c7bc62b | ||
|
|
cca7a10322 | ||
|
|
fe6ef1df0a | ||
|
|
3e27567532 | ||
|
|
0a15e079dc | ||
|
|
48f4fc4a0f | ||
|
|
d9396ce453 | ||
|
|
817c5172b6 | ||
|
|
cfdb20892e | ||
|
|
75922f93bc | ||
|
|
42801e3eca | ||
|
|
64321dbe7c | ||
|
|
b11b52b60c | ||
|
|
00e5470642 | ||
|
|
7ee3b8a196 | ||
|
|
585bb57f64 | ||
|
|
6981fe8f8a | ||
|
|
abdad7fbea | ||
|
|
57b35398be | ||
|
|
47398e6e86 | ||
|
|
e775ae9ada | ||
|
|
6a9a321e2b | ||
|
|
0aee4407db | ||
|
|
a816fa561a | ||
|
|
dc745c3cdb | ||
|
|
0b30c83143 | ||
|
|
dc8804933d | ||
|
|
b28f0bff74 | ||
|
|
e96e3c0760 | ||
|
|
b7ea167420 | ||
|
|
744a55f497 | ||
|
|
b55f999efb | ||
|
|
4e09d9e54a | ||
|
|
5afa333fab | ||
|
|
c0946f5c0e | ||
|
|
3ece4fcf8b | ||
|
|
b1193c5d11 | ||
|
|
12b0afd9c6 | ||
|
|
df45d68b74 | ||
|
|
11bf702e02 | ||
|
|
47e2683aa3 | ||
|
|
e4fc00669e | ||
|
|
f64db1271b | ||
|
|
35647a8361 | ||
|
|
a2c8658972 | ||
|
|
38e18cf0a7 | ||
|
|
78c9974036 | ||
|
|
7a60a6804a | ||
|
|
5e6e2835ca | ||
|
|
180083147d | ||
|
|
50b0b5af37 | ||
|
|
9d27b8d47e | ||
|
|
fbb4dc5e59 | ||
|
|
a2296f2365 | ||
|
|
58ff2e6157 | ||
|
|
ff490a2b14 | ||
|
|
067d694036 | ||
|
|
401d807d29 | ||
|
|
eab202f721 | ||
|
|
f726c656b5 | ||
|
|
37dab1c8b6 | ||
|
|
9a5d8522fb | ||
|
|
5abbd07722 | ||
|
|
13579f9473 | ||
|
|
cea9cdcd34 | ||
|
|
ed22155940 | ||
|
|
43497f2ce0 | ||
|
|
80e58f72f8 | ||
|
|
234751a4b3 | ||
|
|
ef567110d0 | ||
|
|
42b89f438b | ||
|
|
b01df2b38e | ||
|
|
b89efb547e | ||
|
|
e6554a5b4a | ||
|
|
c58b13e2f7 | ||
|
|
241372ce61 | ||
|
|
49ed594f53 | ||
|
|
03b76357ff | ||
|
|
97490bb6ef | ||
|
|
dbf5267c5c | ||
|
|
e1ce638d7f | ||
|
|
88b80a0da1 | ||
|
|
d6b571590a | ||
|
|
83f3af340f | ||
|
|
e9cf9b21a8 | ||
|
|
b1645590fe | ||
|
|
a5da4ea53d | ||
|
|
3a082d131f | ||
|
|
28d501ed02 | ||
|
|
a04fd4a9f2 | ||
|
|
2424dc3961 | ||
|
|
58f1515387 | ||
|
|
9ee3da3910 | ||
|
|
cc44b4dd55 | ||
|
|
b8e4ccfc28 | ||
|
|
0fc6757d4a | ||
|
|
ee94cc6bf6 | ||
|
|
2bda50ad4a | ||
|
|
9a6f88b81b |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -14,10 +14,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up JDK 11
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v1
|
||||
with:
|
||||
java-version: 11
|
||||
java-version: 17
|
||||
|
||||
- name: Cache gradle
|
||||
uses: actions/cache@v1
|
||||
|
||||
26
README.md
26
README.md
@@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [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] Identity Verification (NIP-05)
|
||||
@@ -41,13 +41,23 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtags
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable uploads (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [ ] 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)
|
||||
- [ ] Relay Authentication (NIP-42)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
@@ -59,7 +69,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] Polls (NIP-69)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
@@ -85,7 +95,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
|
||||
## Setup
|
||||
|
||||
Make sure to have the following pre-requisites installed:
|
||||
1. Java 11
|
||||
1. Java 17
|
||||
2. Android Studio
|
||||
3. Android 8.0+ Phone or Emulation setup
|
||||
|
||||
@@ -167,6 +177,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|
||||
|
||||
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
|
||||
</a>
|
||||
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 117
|
||||
versionName "0.32.2"
|
||||
versionCode 126
|
||||
versionName "0.36.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -57,12 +57,12 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '11'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -78,10 +78,14 @@ android {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
@@ -104,13 +108,13 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation "net.engawapg.lib:zoomable:1.4.0"
|
||||
implementation 'net.engawapg.lib:zoomable:1.4.2'
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -123,7 +127,7 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
@@ -133,7 +137,7 @@ dependencies {
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.5'
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -142,15 +146,14 @@ dependencies {
|
||||
// view svgs
|
||||
implementation "io.coil-kt:coil-svg:$coil_version"
|
||||
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// tabs for user profiles
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||
|
||||
// Parses URLs from Text:
|
||||
implementation "io.github.url-detector:url-detector:0.1.23"
|
||||
|
||||
@@ -173,7 +176,7 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "io.mockk:mockk:1.13.4"
|
||||
testImplementation 'io.mockk:mockk:1.13.5'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
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.LnZapRequestEvent.Companion.createEncryptionPrivateKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateZapTests {
|
||||
|
||||
@Test
|
||||
fun testPollZap() {
|
||||
val poll = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "New poll \n\n #zappoll",
|
||||
"created_at": 1682440713,
|
||||
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
|
||||
"kind": 6969,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
|
||||
"tags": [
|
||||
[
|
||||
"poll_option",
|
||||
"0",
|
||||
"Test 1"
|
||||
],
|
||||
[
|
||||
"poll_option",
|
||||
"1",
|
||||
"Test 2"
|
||||
],
|
||||
[
|
||||
"value_maximum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"value_minimum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"consensus_threshold",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"closed_at",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
poll,
|
||||
setOf("wss://relay.damus.io/"),
|
||||
loggedIn,
|
||||
0,
|
||||
"",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind1PrivateZap() {
|
||||
val textNote = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"created_at": 1682369982,
|
||||
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
|
||||
"kind": 1,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
textNote,
|
||||
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
|
||||
loggedIn,
|
||||
null,
|
||||
"test",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
@@ -16,7 +19,7 @@
|
||||
android:name=".Amethyst"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
@@ -48,6 +51,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrwalletconnect" />
|
||||
<data android:scheme="nostr+walletconnect" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
@@ -42,6 +43,7 @@ private object PrefKeys {
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -191,6 +193,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
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)
|
||||
@@ -217,6 +220,11 @@ object LocalPreferences {
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val defaultZapType = gson.fromJson(
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
|
||||
object : TypeToken<LnZapEvent.ZapType>() {}.type
|
||||
) ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
@@ -260,6 +268,7 @@ object LocalPreferences {
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
defaultZapType,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
|
||||
@@ -18,7 +18,7 @@ object VideoCache {
|
||||
|
||||
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
|
||||
|
||||
fun get(context: Context): CacheDataSource.Factory {
|
||||
fun init(context: Context) {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
exoDatabaseProvider = StandaloneDatabaseProvider(context)
|
||||
|
||||
@@ -35,7 +35,9 @@ object VideoCache {
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
fun get(): CacheDataSource.Factory {
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
@@ -42,6 +44,7 @@ class Account(
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -54,8 +57,14 @@ class Account(
|
||||
val liveLanguages: AccountLiveData = AccountLiveData(this)
|
||||
val saveable: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
var userProfileCache: User? = null
|
||||
|
||||
fun userProfile(): User {
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||
return userProfileCache ?: run {
|
||||
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||
userProfileCache = myUser
|
||||
myUser
|
||||
}
|
||||
}
|
||||
|
||||
fun followingChannels(): List<Channel> {
|
||||
@@ -137,7 +146,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? {
|
||||
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
note.event?.let { event ->
|
||||
@@ -147,7 +156,8 @@ class Account(
|
||||
?: localRelays.map { it.url }.toSet(),
|
||||
loggedIn.privKey!!,
|
||||
pollOption,
|
||||
message
|
||||
message,
|
||||
zapType
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -157,13 +167,26 @@ class Account(
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(lnInvoice: String) {
|
||||
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let {
|
||||
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
zapPaymentRequest?.let { nip47 ->
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
Client.send(event, it.relayUri)
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
|
||||
wcListener.start()
|
||||
|
||||
LocalCache.consume(event) {
|
||||
// After the response is received.
|
||||
val privKey = nip47.secret?.toByteArray()
|
||||
if (privKey != null) {
|
||||
onResponse(it.response(privKey, event.pubKey.toByteArray()))
|
||||
}
|
||||
}
|
||||
|
||||
Client.send(event, nip47.relayUri, wcListener.feedTypes) {
|
||||
wcListener.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,14 +194,15 @@ class Account(
|
||||
return createZapRequestFor(user)
|
||||
}
|
||||
|
||||
fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
|
||||
fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return LnZapRequestEvent.create(
|
||||
userPubKeyHex,
|
||||
userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(),
|
||||
loggedIn.privKey!!,
|
||||
message
|
||||
message,
|
||||
zapType
|
||||
)
|
||||
}
|
||||
|
||||
@@ -349,6 +373,25 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendHeader(headerInfo: FileHeader): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val signedEvent = FileHeaderEvent.create(
|
||||
url = headerInfo.url,
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -526,6 +569,12 @@ class Account(
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -597,6 +646,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
|
||||
defaultZapType = zapType
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -642,11 +697,57 @@ class Account(
|
||||
}
|
||||
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
|
||||
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
event?.content()
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapContentAuthor(note: Note): Event? {
|
||||
val event = note.event
|
||||
val loggedInPrivateKey = loggedIn.privKey
|
||||
|
||||
return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) {
|
||||
val recipientPK = event.zappedAuthor().firstOrNull()
|
||||
val recipientPost = event.zappedPost().firstOrNull()
|
||||
|
||||
if (recipientPK == userProfile().pubkeyHex) {
|
||||
// if the receiver is logged in, these are the params.
|
||||
val privateKeyToUse = loggedInPrivateKey
|
||||
val pubkeyToUse = event.pubKey
|
||||
|
||||
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
|
||||
} else {
|
||||
// if the sender is logged in, these are the params
|
||||
val altPubkeyToUse = recipientPK
|
||||
val altPrivateKeyToUse = if (recipientPost != null) {
|
||||
LnZapRequestEvent.createEncryptionPrivateKey(
|
||||
loggedInPrivateKey.toHexKey(),
|
||||
recipientPost,
|
||||
event.createdAt
|
||||
)
|
||||
} else if (recipientPK != null) {
|
||||
LnZapRequestEvent.createEncryptionPrivateKey(
|
||||
loggedInPrivateKey.toHexKey(),
|
||||
recipientPK,
|
||||
event.createdAt
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
liveLanguages.invalidateData()
|
||||
@@ -716,14 +817,15 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
|
||||
fun isHidden(user: User) = isHidden(user.pubkeyHex)
|
||||
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
|
||||
|
||||
fun followingKeySet(): Set<HexKey> {
|
||||
return userProfile().cachedFollowingKeySet() ?: emptySet()
|
||||
return userProfile().cachedFollowingKeySet()
|
||||
}
|
||||
|
||||
fun followingTagSet(): Set<HexKey> {
|
||||
return userProfile().cachedFollowingTagSet() ?: emptySet()
|
||||
return userProfile().cachedFollowingTagSet()
|
||||
}
|
||||
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
|
||||
@@ -6,15 +6,16 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
|
||||
"grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
|
||||
} else if (key.startsWith("nprofile", true)) {
|
||||
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(pubkeyRelay, restOfWord)
|
||||
} else if (key.startsWith("nevent", true)) {
|
||||
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
|
||||
} else if (key.startsWith("nevent1", true)) {
|
||||
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(noteRelayId, restOfWord)
|
||||
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
|
||||
} else if (key.startsWith("naddr1", true)) {
|
||||
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
|
||||
|
||||
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
|
||||
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -4,9 +4,10 @@ import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
@@ -30,6 +31,8 @@ object LocalCache {
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateUser(key)
|
||||
@@ -189,7 +192,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LongTextNoteEvent, relay: Relay?) {
|
||||
@@ -218,7 +221,7 @@ object LocalCache {
|
||||
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +258,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
@@ -268,7 +271,7 @@ object LocalCache {
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList<Note>())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +289,6 @@ object LocalCache {
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
author.updateAcceptedBadges(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +310,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -353,7 +354,7 @@ object LocalCache {
|
||||
recipient.addMessage(author, note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -401,7 +402,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
if (deletedAtLeastOne) {
|
||||
live.invalidateData()
|
||||
// refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +428,7 @@ object LocalCache {
|
||||
it.addBoost(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReactionEvent) {
|
||||
@@ -465,6 +466,8 @@ object LocalCache {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ReportEvent, relay: Relay?) {
|
||||
@@ -495,6 +498,8 @@ object LocalCache {
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
@@ -511,7 +516,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +536,7 @@ object LocalCache {
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -578,7 +583,7 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@@ -591,11 +596,10 @@ object LocalCache {
|
||||
|
||||
fun consume(event: LnZapEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
|
||||
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
|
||||
@@ -620,6 +624,8 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(zapRequest, note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapRequestEvent) {
|
||||
@@ -643,6 +649,37 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addZap(note, null)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileHeaderEvent) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent) {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
awaitingPaymentRequests.put(event.id, onResponse)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val responseCallback = awaitingPaymentRequests[event.requestId()]
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
@@ -694,11 +731,6 @@ object LocalCache {
|
||||
notes.remove(it.idHex)
|
||||
// Doesn't need to clean up the replies and mentions.. Too small to matter.
|
||||
|
||||
// reverts the add
|
||||
val mentions =
|
||||
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
?.mapNotNull { checkGetOrCreateUser(it) }
|
||||
|
||||
// Counts the replies
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
@@ -749,30 +781,23 @@ object LocalCache {
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData()
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.invalidateData()
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData(val cache: LocalCache) :
|
||||
LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(LocalCacheState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheState(val cache: LocalCache)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -20,6 +21,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
|
||||
class AddressableNote(val address: ATag) : Note(address.toTag()) {
|
||||
override fun idNote() = address.toNAddr()
|
||||
override fun toNEvent() = address.toNAddr()
|
||||
override fun idDisplayNote() = idNote().toShortenHex()
|
||||
override fun address() = address
|
||||
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
|
||||
@@ -51,15 +53,21 @@ open class Note(val idHex: String) {
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
|
||||
open fun toNEvent(): String {
|
||||
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
|
||||
}
|
||||
|
||||
open fun idDisplayNote() = idNote().toShortenHex()
|
||||
|
||||
fun channel(): Channel? {
|
||||
val channelHex =
|
||||
(event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.let { it.id }
|
||||
fun channelHex(): HexKey? {
|
||||
return (event as? ChannelMessageEvent)?.channel()
|
||||
?: (event as? ChannelMetadataEvent)?.channel()
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
}
|
||||
|
||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
fun channel(): Channel? {
|
||||
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
@@ -192,17 +200,19 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author == user }
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
return reactions.any { it.author === user }
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author == user }
|
||||
return boosts.any { it.author === user }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): Set<Note> {
|
||||
@@ -270,45 +280,6 @@ open class Note(val idHex: String) {
|
||||
)
|
||||
}
|
||||
|
||||
fun directlyCiteUsersHex(): Set<HexKey> {
|
||||
val matcher = tagSearch.matcher(event?.content() ?: "")
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCiteUsers(): Set<User> {
|
||||
val matcher = tagSearch.matcher(event?.content() ?: "")
|
||||
val returningList = mutableSetOf<User>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
|
||||
if (tag != null && tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
returningList.add(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun directlyCites(userProfile: User): Boolean {
|
||||
return author == userProfile ||
|
||||
(userProfile in directlyCiteUsers()) ||
|
||||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
|
||||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|
||||
}
|
||||
|
||||
fun isNewThread(): Boolean {
|
||||
return event is RepostEvent || replyTo == null || replyTo?.size == 0
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun pubkey() = Hex.decode(pubkeyHex)
|
||||
fun pubkeyNpub() = pubkey().toNpub()
|
||||
|
||||
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
|
||||
|
||||
override fun toString(): String = pubkeyHex
|
||||
@@ -295,7 +296,7 @@ class User(val pubkeyHex: String) {
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = privateChatrooms[user] ?: return false
|
||||
|
||||
return messagesToUser.roomMessages.any { this == it.author }
|
||||
return messagesToUser.roomMessages.any { this === it.author }
|
||||
}
|
||||
|
||||
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.withSign
|
||||
|
||||
object BlurHashDecoder {
|
||||
|
||||
// cache Math.cos() calculations to improve performance.
|
||||
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
|
||||
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
|
||||
private val cacheCosinesX = HashMap<Int, DoubleArray>()
|
||||
private val cacheCosinesY = HashMap<Int, DoubleArray>()
|
||||
|
||||
/**
|
||||
* Clear calculations stored in memory cache.
|
||||
* The cache is not big, but will increase when many image sizes are used,
|
||||
* if the app needs memory it is recommended to clear it.
|
||||
*/
|
||||
fun clearCache() {
|
||||
cacheCosinesX.clear()
|
||||
cacheCosinesY.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns width/height
|
||||
*/
|
||||
fun aspectRatio(blurHash: String?): Float? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return numCompX.toFloat() / numCompY.toFloat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a blur hash into a new bitmap.
|
||||
*
|
||||
* @param useCache use in memory cache for the calculated math, reused by images with same size.
|
||||
* if the cache does not exist yet it will be created and populated with new calculations.
|
||||
* By default it is true.
|
||||
*/
|
||||
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
|
||||
if (blurHash == null || blurHash.length < 6) {
|
||||
return null
|
||||
}
|
||||
val numCompEnc = decode83(blurHash, 0, 1)
|
||||
val numCompX = (numCompEnc % 9) + 1
|
||||
val numCompY = (numCompEnc / 9) + 1
|
||||
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
|
||||
return null
|
||||
}
|
||||
val maxAcEnc = decode83(blurHash, 1, 2)
|
||||
val maxAc = (maxAcEnc + 1) / 166f
|
||||
val colors = Array(numCompX * numCompY) { i ->
|
||||
if (i == 0) {
|
||||
val colorEnc = decode83(blurHash, 2, 6)
|
||||
decodeDc(colorEnc)
|
||||
} else {
|
||||
val from = 4 + i * 2
|
||||
val colorEnc = decode83(blurHash, from, from + 2)
|
||||
decodeAc(colorEnc, maxAc * punch)
|
||||
}
|
||||
}
|
||||
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
|
||||
}
|
||||
|
||||
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
|
||||
var result = 0
|
||||
for (i in from until to) {
|
||||
val index = charMap[str[i]] ?: -1
|
||||
if (index != -1) {
|
||||
result = result * 83 + index
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun decodeDc(colorEnc: Int): FloatArray {
|
||||
val r = colorEnc shr 16
|
||||
val g = (colorEnc shr 8) and 255
|
||||
val b = colorEnc and 255
|
||||
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
|
||||
}
|
||||
|
||||
private fun srgbToLinear(colorEnc: Int): Float {
|
||||
val v = colorEnc / 255f
|
||||
return if (v <= 0.04045f) {
|
||||
(v / 12.92f)
|
||||
} else {
|
||||
((v + 0.055f) / 1.055f).pow(2.4f)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
|
||||
val r = value / (19 * 19)
|
||||
val g = (value / 19) % 19
|
||||
val b = value % 19
|
||||
return floatArrayOf(
|
||||
signedPow2((r - 9) / 9.0f) * maxAc,
|
||||
signedPow2((g - 9) / 9.0f) * maxAc,
|
||||
signedPow2((b - 9) / 9.0f) * maxAc
|
||||
)
|
||||
}
|
||||
|
||||
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
|
||||
|
||||
private fun composeBitmap(
|
||||
width: Int,
|
||||
height: Int,
|
||||
numCompX: Int,
|
||||
numCompY: Int,
|
||||
colors: Array<FloatArray>,
|
||||
useCache: Boolean
|
||||
): Bitmap {
|
||||
// use an array for better performance when writing pixel colors
|
||||
val imageArray = IntArray(width * height)
|
||||
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
|
||||
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
|
||||
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
|
||||
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
var r = 0f
|
||||
var g = 0f
|
||||
var b = 0f
|
||||
for (j in 0 until numCompY) {
|
||||
for (i in 0 until numCompX) {
|
||||
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
|
||||
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
|
||||
val basis = (cosX * cosY).toFloat()
|
||||
val color = colors[j * numCompX + i]
|
||||
r += color[0] * basis
|
||||
g += color[1] * basis
|
||||
b += color[2] * basis
|
||||
}
|
||||
}
|
||||
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
|
||||
}
|
||||
}
|
||||
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(height * numCompY).also {
|
||||
cacheCosinesY[height * numCompY] = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cacheCosinesY[height * numCompY]!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
|
||||
calculate -> {
|
||||
DoubleArray(width * numCompX).also {
|
||||
cacheCosinesX[width * numCompX] = it
|
||||
}
|
||||
}
|
||||
else -> cacheCosinesX[width * numCompX]!!
|
||||
}
|
||||
|
||||
private fun DoubleArray.getCos(
|
||||
calculate: Boolean,
|
||||
x: Int,
|
||||
numComp: Int,
|
||||
y: Int,
|
||||
size: Int
|
||||
): Double {
|
||||
if (calculate) {
|
||||
this[x + numComp * y] = cos(Math.PI * y * x / size)
|
||||
}
|
||||
return this[x + numComp * y]
|
||||
}
|
||||
|
||||
private fun linearToSrgb(value: Float): Int {
|
||||
val v = value.coerceIn(0f, 1f)
|
||||
return if (v <= 0.0031308f) {
|
||||
(v * 12.92f * 255f + 0.5f).toInt()
|
||||
} else {
|
||||
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private val charMap = listOf(
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
|
||||
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
|
||||
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
|
||||
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
|
||||
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
|
||||
)
|
||||
.mapIndexed { i, c -> c to i }
|
||||
.toMap()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class BlurHashFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
|
||||
|
||||
val preferredWidth = 100
|
||||
|
||||
val bitmap = BlurHashDecoder.decode(
|
||||
hash,
|
||||
preferredWidth,
|
||||
(preferredWidth * (1 / aspectRatio)).roundToInt()
|
||||
)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to convert Bluehash $hash")
|
||||
}
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return BlurHashFetcher(options, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object BlurHashRequester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FileHeader(
|
||||
val url: String,
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(imageData).toHexKey()
|
||||
val size = imageData.size
|
||||
|
||||
val blurHash = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
|
||||
|
||||
val intArray = IntArray(mBitmap.width * mBitmap.height)
|
||||
mBitmap.getPixels(
|
||||
intArray,
|
||||
0,
|
||||
mBitmap.width,
|
||||
0,
|
||||
0,
|
||||
mBitmap.width,
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
|
||||
} else {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ object Nip47 {
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect") {
|
||||
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
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.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
@@ -25,7 +27,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountContactListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -36,7 +38,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountMetadataFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -47,7 +49,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountAcceptedAwardsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -58,7 +60,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountBookmarkListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -69,7 +71,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
@@ -79,7 +81,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
@@ -88,6 +90,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
RepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
@@ -112,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -43,7 +44,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
@@ -54,7 +55,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
fun createLastChannelInfoFilter(): List<TypedFilter> {
|
||||
return account.followingChannels.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(), // Metadata comes from any relay
|
||||
types = COMMON_FEED_TYPES, // Metadata comes from any relay
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
|
||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
@@ -75,11 +76,14 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is FileHeaderEvent -> LocalCache.consume(event)
|
||||
is LnZapEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
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)
|
||||
@@ -122,12 +126,21 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
Client.subscribe(clientListener)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
}
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
resetFilters()
|
||||
@@ -154,7 +167,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
@@ -212,4 +225,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -15,9 +15,16 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
|
||||
val hashToLoad = hashtagToWatch ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
|
||||
tags = mapOf(
|
||||
"t" to listOf(
|
||||
hashToLoad,
|
||||
hashToLoad.lowercase(),
|
||||
hashToLoad.uppercase(),
|
||||
hashToLoad.capitalize()
|
||||
)
|
||||
),
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private var fromServiceHex: String,
|
||||
private var toUserHex: String,
|
||||
private var replyingToHex: String
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
|
||||
private fun createWalletConnectServiceWatcher(): TypedFilter {
|
||||
// downloads all the reactions to a given event.
|
||||
return TypedFilter(
|
||||
types = feedTypes,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapPaymentResponseEvent.kind),
|
||||
authors = listOf(fromServiceHex),
|
||||
tags = mapOf(
|
||||
"e" to listOf(replyingToHex),
|
||||
"p" to listOf(toUserHex)
|
||||
),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val channel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val wc = createWalletConnectServiceWatcher()
|
||||
|
||||
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -34,7 +34,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return listOfNotNull(
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
)
|
||||
@@ -42,7 +42,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
},
|
||||
hexToWatch?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
@@ -50,7 +50,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
@@ -58,7 +58,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
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
|
||||
@@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
|
||||
|
||||
// downloads linked events to this event.
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
|
||||
@@ -3,11 +3,10 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import java.util.Date
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
@@ -20,12 +19,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind,
|
||||
@@ -49,12 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
return null
|
||||
}
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
@@ -74,7 +69,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
return reactionsToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
@@ -113,12 +108,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
// downloads linked events to this event.
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind, FileHeaderEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -16,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -31,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
return usersToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -18,14 +18,14 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
|
||||
val loadEventsChannel = requestNewChannel() { _, _ ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
@@ -22,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -33,7 +33,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserPostsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -44,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createUserReceivedZapsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(LnZapEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -54,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -65,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createFollowersFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex))
|
||||
@@ -75,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createAcceptedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeProfilesEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -86,7 +86,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createBookmarksFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
@@ -97,7 +97,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BadgeAwardEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
|
||||
|
||||
open class BaseTextNoteEvent(
|
||||
id: HexKey,
|
||||
@@ -15,6 +17,48 @@ open class BaseTextNoteEvent(
|
||||
fun mentions() = taggedUsers()
|
||||
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
private var citedUsersCache: Set<HexKey>? = null
|
||||
|
||||
fun citedUsers(): Set<HexKey> {
|
||||
citedUsersCache?.let { return it }
|
||||
|
||||
val matcher = tagSearch.matcher(content)
|
||||
val returningList = mutableSetOf<String>()
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag.size > 1 && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
citedUsersCache = returningList
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun findCitations(): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
// Removes citations from replies:
|
||||
@@ -22,15 +66,44 @@ open class BaseTextNoteEvent(
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
val tag = matcher.group(1)?.let { tags[it.toInt()] }
|
||||
if (tag != null && tag[0] == "e") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
if (tag != null && tag.size > 1 && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
val matcher2 = nip19regex.matcher(content)
|
||||
while (matcher2.find()) {
|
||||
val uriScheme = matcher2.group(1) // nostr:
|
||||
val type = matcher2.group(2) // npub1
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
if (content.contains("Testing event")) {
|
||||
println("AAAA $key")
|
||||
}
|
||||
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "e") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
if (tag != null && tag[0] == "a") {
|
||||
citations.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return citations
|
||||
}
|
||||
|
||||
|
||||
@@ -38,23 +38,23 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
val aTagValue = it[1]
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
ATag.parse(aTagValue, relay)
|
||||
}
|
||||
|
||||
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
|
||||
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
|
||||
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
@@ -79,7 +79,7 @@ open class Event(
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -222,8 +222,10 @@ open class Event(
|
||||
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -24,11 +24,11 @@ interface EventInterface {
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(loggedInUser: String): Boolean
|
||||
fun isTaggedUser(idHex: String): Boolean
|
||||
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtag: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtag: Set<String>): String?
|
||||
fun isTaggedHashes(hashtags: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtags: Set<String>): String?
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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 FileHeaderEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
|
||||
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 1063
|
||||
|
||||
private const val URL = "url"
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
private const val BLUR_HASH = "blurhash"
|
||||
|
||||
fun create(
|
||||
url: String,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
encryptionKey: AESGCM? = null,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): FileHeaderEvent {
|
||||
var tags = listOfNotNull(
|
||||
listOf(URL, url),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
|
||||
)
|
||||
|
||||
val content = description ?: ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class AESGCM(val key: String, val nonce: String)
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.math.BigDecimal
|
||||
|
||||
class LnZapEvent(
|
||||
id: HexKey,
|
||||
@@ -14,25 +13,37 @@ class LnZapEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
// This event is also kept in LocalCache (same object)
|
||||
@Transient val zapRequest: LnZapRequestEvent?
|
||||
|
||||
override fun zappedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun zappedPollOption(): Int? = containedPost()?.tags
|
||||
?.filter { it.firstOrNull() == POLL_OPTION }
|
||||
?.getOrNull(0)?.getOrNull(1)?.toInt()
|
||||
|
||||
override fun zappedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
|
||||
|
||||
override fun amount(): BigDecimal? {
|
||||
return amount
|
||||
override fun containedPost(): LnZapRequestEvent? = try {
|
||||
description()?.ifBlank { null }?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
} as? LnZapRequestEvent
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
}
|
||||
|
||||
init {
|
||||
zapRequest = containedPost()
|
||||
}
|
||||
|
||||
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
override fun zappedPollOption(): Int? = try {
|
||||
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
|
||||
null
|
||||
}
|
||||
|
||||
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
|
||||
|
||||
override fun amount() = amount
|
||||
|
||||
// Keeps this as a field because it's a heavier function used everywhere.
|
||||
val amount by lazy {
|
||||
try {
|
||||
@@ -42,31 +53,22 @@ class LnZapEvent(
|
||||
null
|
||||
}
|
||||
}
|
||||
override fun message(): String {
|
||||
return message
|
||||
}
|
||||
val message = content
|
||||
|
||||
override fun containedPost(): Event? = try {
|
||||
description()?.ifBlank { null }?.let {
|
||||
fromJson(it, Client.lenient)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
|
||||
null
|
||||
override fun content(): String {
|
||||
return content
|
||||
}
|
||||
|
||||
private fun lnInvoice(): String? = tags
|
||||
.filter { it.firstOrNull() == "bolt11" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description(): String? = tags
|
||||
.filter { it.firstOrNull() == "description" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
.firstOrNull()
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 9735
|
||||
}
|
||||
|
||||
enum class ZapType() {
|
||||
PUBLIC,
|
||||
PRIVATE,
|
||||
ANONYMOUS,
|
||||
NONZAP
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,4 @@ interface LnZapEventInterface : EventInterface {
|
||||
fun amount(): BigDecimal?
|
||||
|
||||
fun containedPost(): Event?
|
||||
|
||||
fun message(): String
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class LnZapPaymentRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun lnInvoice(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
@@ -37,9 +39,10 @@ class LnZapPaymentRequestEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
|
||||
|
||||
val content = Utils.encrypt(
|
||||
lnInvoice,
|
||||
serializedRequest,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
)
|
||||
@@ -53,3 +56,14 @@ class LnZapPaymentRequestEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REQUEST OBJECTS
|
||||
|
||||
abstract class Request(val method: String, val params: Params)
|
||||
abstract class Params
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
|
||||
class PayInvoiceParams(val invoice: String) : Params()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
val retVal = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
|
||||
retVal.substring(16)
|
||||
} else {
|
||||
retVal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 23195
|
||||
}
|
||||
}
|
||||
|
||||
// RESPONSE OBJECTS
|
||||
abstract class Response(
|
||||
@SerializedName("result_type")
|
||||
val resultType: String
|
||||
)
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
|
||||
class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
|
||||
|
||||
enum class ErrorType {
|
||||
@SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
|
||||
RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
|
||||
@SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
|
||||
NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
|
||||
@SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
|
||||
INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
|
||||
@SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
|
||||
QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
|
||||
@SerializedName(value = "restricted", alternate = ["RESTRICTED"])
|
||||
RESTRICTED, // This public key is not allowed to do this operation.
|
||||
@SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
|
||||
UNAUTHORIZED, // This public key has no wallet connected.
|
||||
@SerializedName(value = "internal", alternate = ["INTERNAL"])
|
||||
INTERNAL, // An internal error.
|
||||
@SerializedName(value = "other", alternate = ["OTHER"])
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
import java.nio.charset.Charset
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class LnZapRequestEvent(
|
||||
id: HexKey,
|
||||
@@ -13,8 +19,12 @@ class LnZapRequestEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
@@ -25,10 +35,12 @@ class LnZapRequestEvent(
|
||||
privateKey: ByteArray,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
listOf("p", originalNote.pubKey()),
|
||||
@@ -40,9 +52,21 @@ class LnZapRequestEvent(
|
||||
if (pollOption != null && pollOption >= 0) {
|
||||
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
|
||||
}
|
||||
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = "" // make sure public content is empty, as the content is encrypted
|
||||
privkey = encryptionPrivateKey // sign event with generated privkey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
@@ -51,22 +75,102 @@ class LnZapRequestEvent(
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
var content = message
|
||||
var privkey = privateKey
|
||||
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("p", userHex),
|
||||
listOf("relays") + relays
|
||||
)
|
||||
|
||||
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
privkey = Utils.privkeyCreate()
|
||||
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
|
||||
tags = tags + listOf(listOf("anon", ""))
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
val sig = Utils.sign(id, privkey)
|
||||
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
|
||||
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
|
||||
var str = privkey + id + createdAt.toString()
|
||||
var strbyte = str.toByteArray(Charset.forName("utf-8"))
|
||||
return sha256.digest(strbyte)
|
||||
}
|
||||
|
||||
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
|
||||
val keySpec = SecretKeySpec(sharedSecret, "AES")
|
||||
val ivSpec = IvParameterSpec(iv)
|
||||
|
||||
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
|
||||
val encryptedMsg = cipher.doFinal(utf8message)
|
||||
|
||||
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
|
||||
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
|
||||
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
|
||||
throw IllegalArgumentException("Invalid shared secret size")
|
||||
}
|
||||
val parts = msg.split("_")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid message format")
|
||||
}
|
||||
val iv = parts[1].run { Bech32.decode(this).second }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
|
||||
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
|
||||
|
||||
try {
|
||||
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedMsgBytes)
|
||||
} catch (ex: BadPaddingException) {
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
return decryptedEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
{
|
||||
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
|
||||
@@ -20,24 +20,17 @@ class PollNoteEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun pollOptions(): Map<Int, String> {
|
||||
val map = mutableMapOf<Int, String>()
|
||||
tags.filter { it.first() == POLL_OPTION }
|
||||
.forEach { map[it[1].toInt()] = it[2] }
|
||||
return map
|
||||
}
|
||||
fun pollOptions() =
|
||||
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
|
||||
.associate { it[1].toInt() to it[2] }
|
||||
|
||||
fun getTagInt(property: String): Int? {
|
||||
val tagList = tags.filter {
|
||||
it.firstOrNull() == property
|
||||
}
|
||||
val tag = tagList.getOrNull(0)
|
||||
val s = tag?.getOrNull(1)
|
||||
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
|
||||
|
||||
return if (s.isNullOrBlank() || s == "null") {
|
||||
return if (number.isNullOrBlank() || number == "null") {
|
||||
null
|
||||
} else {
|
||||
s.toInt()
|
||||
number.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
@@ -29,7 +29,7 @@ class PrivateDmEvent(
|
||||
* Nip-18 messages should refer to other events by inline references in the content like
|
||||
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
|
||||
@@ -14,8 +14,8 @@ class ReactionEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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 RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
|
||||
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("relay", relay),
|
||||
listOf("challenge", challenge)
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedPost() = tags
|
||||
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "e" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
@@ -39,7 +39,7 @@ class ReportEvent(
|
||||
}
|
||||
|
||||
fun reportedAuthor() = tags
|
||||
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
|
||||
.filter { it.size > 1 && it[0] == "p" }
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toByteArray
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
@@ -12,7 +15,14 @@ object Nip19 {
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Long? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
if (uri == null) return null
|
||||
@@ -26,8 +36,23 @@ object Nip19 {
|
||||
val uriScheme = matcher.group(1) // nostr:
|
||||
val type = matcher.group(2) // npub1
|
||||
val key = matcher.group(3) // bech32
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
val additionalChars = matcher.group(4) // additional chars
|
||||
|
||||
return parseComponents(uriScheme, type, key, additionalChars)
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseComponents(
|
||||
uriScheme: String?,
|
||||
type: String,
|
||||
key: String?,
|
||||
additionalChars: String?
|
||||
): Return? {
|
||||
return try {
|
||||
val bytes = (type + key).bechToBytes()
|
||||
val parsed = when (type.lowercase()) {
|
||||
"npub1" -> npub(bytes)
|
||||
@@ -38,12 +63,11 @@ object Nip19 {
|
||||
"naddr1" -> naddr(bytes)
|
||||
else -> null
|
||||
}
|
||||
return parsed?.copy(additionalChars = additionalChars)
|
||||
parsed?.copy(additionalChars = additionalChars ?: "")
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
|
||||
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun npub(bytes: ByteArray): Return {
|
||||
@@ -79,7 +103,15 @@ object Nip19 {
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.EVENT, hex, relay)
|
||||
val author = tlv.get(Tlv.Type.AUTHOR.id)
|
||||
?.get(0)
|
||||
?.toHexKey()
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
@@ -108,8 +140,31 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
|
||||
val kind = kind?.toByteArray()
|
||||
val author = author?.toByteArray()
|
||||
val idHex = idHex.toByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
if (relay != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
|
||||
}
|
||||
|
||||
if (author != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
|
||||
}
|
||||
|
||||
if (kind != null) {
|
||||
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
|
||||
}
|
||||
|
||||
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
@@ -64,21 +65,49 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null) {
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelay = relays.filter { it.url == relay }
|
||||
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
|
||||
|
||||
if (useConnectedRelay.isNotEmpty()) {
|
||||
useConnectedRelay.forEach {
|
||||
if (useConnectedRelayIfPresent.isNotEmpty()) {
|
||||
useConnectedRelayIfPresent.forEach {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
} else {
|
||||
/** temporary connection */
|
||||
Relay(relay, false, true, emptySet()).requestAndWatch() {
|
||||
it.send(signedEvent)
|
||||
it.disconnect()
|
||||
newSporadicRelay(
|
||||
relay,
|
||||
feedTypes,
|
||||
onConnected = { relay ->
|
||||
relay.send(signedEvent)
|
||||
},
|
||||
onDone = onDone
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
allSubscriptions().forEach {
|
||||
relay.sendFilter(requestId = it)
|
||||
}
|
||||
|
||||
onConnected(relay)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(10000) // waits for a reply
|
||||
relay.disconnect()
|
||||
RelayPool.removeRelay(relay)
|
||||
|
||||
if (onDone != null) {
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +160,14 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -167,5 +204,7 @@ object Client : RelayPool.Listener {
|
||||
* When an relay saves or rejects a new event.
|
||||
*/
|
||||
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
|
||||
|
||||
open fun onAuth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import okhttp3.WebSocketListener
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
|
||||
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
|
||||
}
|
||||
|
||||
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
|
||||
class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
@@ -93,11 +95,15 @@ class Relay(
|
||||
val type = msg[0].asString
|
||||
val channel = msg[1].asString
|
||||
|
||||
// Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}")
|
||||
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient))
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
@@ -113,9 +119,13 @@ class Relay(
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onOK $url, $channel")
|
||||
// Log.w("Relay", "Relay on OK $url, $channel")
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
"AUTH" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay AUTH $url, $channel")
|
||||
it.onAuth(this@Relay, msg[1].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
@@ -266,6 +276,8 @@ class Relay(
|
||||
|
||||
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*
|
||||
|
||||
@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
|
||||
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
@@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() {
|
||||
null
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(this.applicationContext)
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
|
||||
@@ -15,7 +15,7 @@ object ImageUploader {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentResolver: ContentResolver,
|
||||
onSuccess: (String) -> Unit,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
@@ -64,7 +64,7 @@ object ImageUploader {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url)
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
open fun addUserToMentions(user: User) {
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
}
|
||||
|
||||
open fun addNoteToReplyTos(note: Note) {
|
||||
fun addNoteToReplyTos(note: Note) {
|
||||
note.author?.let { addUserToMentions(it) }
|
||||
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
|
||||
}
|
||||
|
||||
open fun tagIndex(user: User): Int {
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
open fun tagIndex(note: Note): Int {
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
@@ -56,19 +56,19 @@ class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var rep
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = LocalCache.getOrCreateUser(results.key.hex)
|
||||
|
||||
"#[${tagIndex(user)}]${results.restOfWord}"
|
||||
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
"nostr:${note.idNote()}${results.restOfWord}"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
|
||||
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.error,
|
||||
unfocusedBorderColor = Color.Red
|
||||
)
|
||||
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
|
||||
focusedBorderColor = MaterialTheme.colors.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Row {
|
||||
val deleteIcon: @Composable (() -> Unit) = {
|
||||
IconButton(
|
||||
|
||||
@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
Text(stringResource(R.string.poll_heading_required))
|
||||
// NewPollRecipientsField(pollViewModel, account)
|
||||
NewPollPrimaryDescription(pollViewModel)
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, element ->
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(pollViewModel, index)
|
||||
}
|
||||
Button(
|
||||
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { index, item ->
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
pollViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MonetizationOn
|
||||
import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -25,18 +34,22 @@ 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.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -44,12 +57,16 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
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.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
@@ -166,7 +183,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
)
|
||||
|
||||
if (postViewModel.wantsPoll) {
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, element ->
|
||||
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(postViewModel, index)
|
||||
}
|
||||
|
||||
@@ -185,10 +202,23 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
val url = postViewModel.contentToAddUrl
|
||||
if (url != null) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
onAdd = { description ->
|
||||
postViewModel.upload(url, description, context)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val user = postViewModel.account?.userProfile()
|
||||
val lud16 = user?.info?.lnAddress()
|
||||
|
||||
if (lud16 != null && user != null && postViewModel.wantsInvoice) {
|
||||
if (lud16 != null && postViewModel.wantsInvoice) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
@@ -233,6 +263,14 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (isBechLink(myUrlPreview)) {
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
MaterialTheme.colors.background,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
@@ -266,16 +304,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
postViewModel.selectImage(it)
|
||||
}
|
||||
|
||||
if (postViewModel.canUsePoll()) {
|
||||
if (postViewModel.canUsePoll) {
|
||||
val hashtag = stringResource(R.string.poll_hashtag)
|
||||
AddPollButton(postViewModel.wantsPoll) {
|
||||
postViewModel.wantsPoll = !postViewModel.wantsPoll
|
||||
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
if (postViewModel.canAddLnInvoice()) {
|
||||
if (postViewModel.canAddInvoice) {
|
||||
AddLnInvoiceButton(postViewModel.wantsInvoice) {
|
||||
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
|
||||
}
|
||||
@@ -327,17 +367,17 @@ private fun AddLnInvoiceButton(
|
||||
) {
|
||||
if (!isLnInvoiceActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MonetizationOn,
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onBackground
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MonetizationOn,
|
||||
imageVector = Icons.Default.CurrencyBitcoin,
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Green.copy(alpha = 0.52f)
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -444,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
onAdd: (String) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 30.dp, end = 30.dp)
|
||||
.clip(shape = RoundedCornerShape(10.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isImage) {
|
||||
R.string.content_description_add_image
|
||||
} else {
|
||||
if (isVideo) {
|
||||
R.string.content_description_add_video
|
||||
} else {
|
||||
R.string.content_description_add_document
|
||||
}
|
||||
}
|
||||
),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1.0f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp),
|
||||
onClick = onCancel
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cancel,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(30.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
) {
|
||||
if (mediaType.startsWith("image")) {
|
||||
AsyncImage(
|
||||
model = uri.toString(),
|
||||
contentDescription = uri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri)
|
||||
}
|
||||
}
|
||||
|
||||
var message by remember { mutableStateOf("") }
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.content_description_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
onAdd(message)
|
||||
},
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
@@ -37,7 +38,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
|
||||
// Images and Videos
|
||||
var contentToAddUrl by mutableStateOf<Uri?>(null)
|
||||
|
||||
// Polls
|
||||
var canUsePoll by mutableStateOf(false)
|
||||
var wantsPoll by mutableStateOf(false)
|
||||
var zapRecipients = mutableStateListOf<HexKey>()
|
||||
var pollOptions = newStateMapPollOptions()
|
||||
@@ -53,6 +58,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
var isValidClosedAt = mutableStateOf(true)
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
@@ -70,15 +76,20 @@ open class NewPostViewModel : ViewModel() {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
} ?: {
|
||||
} ?: run {
|
||||
replyTos = null
|
||||
mentions = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
this.account = account
|
||||
}
|
||||
|
||||
@@ -99,20 +110,15 @@ open class NewPostViewModel : ViewModel() {
|
||||
cancel()
|
||||
}
|
||||
|
||||
fun upload(it: Uri, context: Context) {
|
||||
fun upload(it: Uri, description: String, context: Context) {
|
||||
isUploadingImage = true
|
||||
contentToAddUrl = null
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(2000)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP97Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
@@ -125,6 +131,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
open fun cancel() {
|
||||
message = TextFieldValue("")
|
||||
contentToAddUrl = null
|
||||
urlPreview = null
|
||||
isUploadingImage = false
|
||||
mentions = null
|
||||
@@ -188,14 +195,60 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
|
||||
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
|
||||
}
|
||||
|
||||
fun canUsePoll(): Boolean {
|
||||
return originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
|
||||
if (include) {
|
||||
updateMessage(TextFieldValue(message.text + " $hashtag"))
|
||||
} else {
|
||||
updateMessage(
|
||||
TextFieldValue(
|
||||
message.text.replace(" $hashtag", "")
|
||||
.replace(hashtag, "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun canAddLnInvoice(): Boolean {
|
||||
return account?.userProfile()?.info?.lnAddress() != null
|
||||
fun createNIP97Record(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,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
if (note == null) {
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
} else {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectImage(uri: Uri) {
|
||||
contentToAddUrl = uri
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
|
||||
@@ -13,9 +13,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
|
||||
@Composable
|
||||
fun FabColumn(account: Account) {
|
||||
@@ -88,7 +86,7 @@ fun FabColumn(account: Account) {
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
|
||||
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
|
||||
}
|
||||
|
||||
if (wantsToPoll) {
|
||||
|
||||
@@ -16,18 +16,20 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewNoteButton(account: Account) {
|
||||
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewPostView({ wantsToPost = false }, account = account)
|
||||
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
@@ -44,3 +45,37 @@ class BundledUpdate(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
*/
|
||||
class BundledInsert<T>(
|
||||
val delay: Long,
|
||||
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
|
||||
}
|
||||
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
try {
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
delay(delay)
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
onlyOneInBlock.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baesNote: Note,
|
||||
baseNote: Note,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baseNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,34 @@ import android.net.Uri
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun MayBeWithdrawal(lnurlWord: String) {
|
||||
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnurlWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
|
||||
}
|
||||
}
|
||||
|
||||
lnWithdrawal?.let {
|
||||
ClickableWithdrawal(withdrawalString = it)
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnurlWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
|
||||
@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String) {
|
||||
val amount = try {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount = try {
|
||||
LnInvoiceUtil.getAmountInSats(myInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice = Pair(myInvoice, myInvoiceAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lnInvoice?.let {
|
||||
InvoicePreview(it.first, it.second)
|
||||
}
|
||||
?: Text(
|
||||
text = "$lnbcWord ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
|
||||
@@ -135,7 +135,7 @@ fun InvoiceRequest(
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -14,7 +13,8 @@ import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -33,13 +33,18 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
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.withContext
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
@@ -80,109 +85,88 @@ fun RichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Column(modifier = modifier.animateContentSize()) {
|
||||
Column(modifier = modifier) {
|
||||
if (content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = content,
|
||||
content = markdownWithSpecialContent,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imagesForPager = mutableListOf<String>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
// sequence of images will render in a slideview
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
}
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableImage(fullUrl)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableVideo(fullUrl)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
|
||||
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else {
|
||||
UrlPreview(word, "$word ")
|
||||
}
|
||||
val img = imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, imageList)
|
||||
} else if (urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
val lnInvoice = LnInvoiceUtil.findInvoice(word)
|
||||
if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
@@ -206,7 +190,7 @@ fun RichTextViewer(
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -219,8 +203,15 @@ fun RichTextViewer(
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -228,7 +219,7 @@ fun RichTextViewer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (isValidURL(word)) {
|
||||
if (urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
@@ -263,7 +254,7 @@ fun RichTextViewer(
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -276,8 +267,15 @@ fun RichTextViewer(
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -292,79 +290,149 @@ fun RichTextViewer(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
|
||||
if (parsedNip19.type == Nip19.Type.USER) {
|
||||
val userHex = parsedNip19.hex
|
||||
val userBase = LocalCache.getOrCreateUser(userHex)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val displayName = userState?.user?.bestDisplayName()
|
||||
if (displayName !== null) {
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
returnContent += "[$word]($word) "
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val displayName = getDisplayNameFromUserNip19(parsedNip19)
|
||||
if (displayName != null) {
|
||||
"[@$displayName](nostr://$word) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
|
||||
private fun isArabic(text: String): Boolean {
|
||||
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
|
||||
}
|
||||
|
||||
fun isBechLink(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7)
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val nip19Route = Nip19.uriToRoute(word)
|
||||
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
} else {
|
||||
if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateNote(nip19Route.hex)
|
||||
if (note != null) {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
Nip19.uriToRoute(word)?.let {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNotePair = Pair(note, it.additionalChars)
|
||||
}
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
nip19Route = it
|
||||
}
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
}
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
}
|
||||
}
|
||||
|
||||
if (canPreview) {
|
||||
baseNotePair?.let {
|
||||
NoteCompose(
|
||||
baseNote = it.first,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
if (!it.second.isNullOrEmpty()) {
|
||||
Text(
|
||||
"${it.second} "
|
||||
)
|
||||
}
|
||||
} ?: nip19Route?.let {
|
||||
ClickableRoute(it, navController)
|
||||
} ?: Text(text = "$word ")
|
||||
} else {
|
||||
nip19Route?.let {
|
||||
ClickableRoute(it, navController)
|
||||
} ?: Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
fun HashTag(word: String, navController: NavController) {
|
||||
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
|
||||
val (tag, suffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
tagSuffixPair = Pair(myTag, mySuffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tag != null) {
|
||||
val hashtagIcon = checkForHashtagWithIcon(tag)
|
||||
tagSuffixPair?.let { tagPair ->
|
||||
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("#$tag")
|
||||
append("#${tagPair.first}")
|
||||
}
|
||||
},
|
||||
onClick = { navController.navigate("Hashtag/$tag") }
|
||||
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
@@ -387,14 +455,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
if (hashtagIcon != null) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -405,69 +471,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
Text(text = "$suffix ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
tagPair.second?.ifBlank { "" }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
} ?: Text(text = "$word ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
|
||||
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
|
||||
|
||||
val (index, extraCharacters) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index == null) {
|
||||
return Text(text = "$word ")
|
||||
}
|
||||
|
||||
if (index >= 0 && index < tags.size) {
|
||||
if (tags[index][0] == "p") {
|
||||
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
|
||||
if (baseUser != null) {
|
||||
ClickableUserTag(baseUser, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
LaunchedEffect(key1 = word) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
|
||||
if (note != null) {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(note, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
|
||||
if (index != null && index >= 0 && index < tags.size) {
|
||||
val tag = tags[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
baseUserPair = Pair(it, suffix)
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
baseNotePair = Pair(it, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
Text(text = "$word ")
|
||||
}
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
baseUserPair?.let {
|
||||
ClickableUserTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
}
|
||||
|
||||
baseNotePair?.let {
|
||||
if (canPreview) {
|
||||
NoteCompose(
|
||||
baseNote = it.first,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
it.second?.ifBlank { null }?.let {
|
||||
Text(text = "$it ")
|
||||
}
|
||||
} else {
|
||||
ClickableNoteTag(it.first, navController)
|
||||
Text(text = "${it.second} ")
|
||||
}
|
||||
}
|
||||
|
||||
if (baseNotePair == null && baseUserPair == null) {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import okio.Buffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
|
||||
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
private val size: Size,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
@@ -79,16 +81,17 @@ class HashImageFetcher(
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, data)
|
||||
return HashImageFetcher(options.context, options.size, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
object Robohash {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.size(robotSize)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.size.Size
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
robot: String,
|
||||
robotSize: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
||||
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(LocalContext.current, robot),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var imageErrors = setOf<String>()
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
robot: String,
|
||||
model: String?,
|
||||
robotSize: Dp,
|
||||
model: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
|
||||
if (imageErrors.contains(model)) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = robotSize,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
val context = LocalContext.current
|
||||
val painter = with(LocalDensity.current) {
|
||||
rememberAsyncImagePainter(
|
||||
model = Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality,
|
||||
onError = {
|
||||
imageErrors = imageErrors + model
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (model.url == null) {
|
||||
val proxy = model.proxyUrl()
|
||||
if (proxy == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
robotSize = model.size,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
|
||||
} else {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model.proxyUrl(),
|
||||
robotSize = model.size,
|
||||
model = proxy,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.PagerState
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -39,7 +39,7 @@ fun SlidingCarousel(
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(count = itemsCount, state = pagerState) { page ->
|
||||
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
|
||||
itemContent(page)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
@@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
@@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
@@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
LazyColumn() {
|
||||
itemsIndexed(options) { index, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp)
|
||||
@@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
onSelect(index)
|
||||
}
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
Column() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
explainers?.getOrNull(index)?.let {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
|
||||
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
val url = URL(previewInfo.url)
|
||||
val validatedUrl = URL(previewInfo.url)
|
||||
|
||||
// correctly treating relative images
|
||||
val imageUrl = if (previewInfo.image.startsWith("/")) {
|
||||
URL(url, previewInfo.image).toString()
|
||||
URL(validatedUrl, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, url),
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = url.host,
|
||||
text = validatedUrl.host,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
@@ -19,46 +25,73 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
VideoView(Uri.parse(videoUri), description, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
description?.let {
|
||||
mediaBuilder.setMediaMetadata(
|
||||
MediaMetadata.Builder().setDisplayTitle(it).build()
|
||||
)
|
||||
}
|
||||
|
||||
val media = mediaBuilder.build()
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
val playerView = remember {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
innerOnDialog(it)
|
||||
DisposableEffect(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
val lifecycle = lifecycleOwner.value.lifecycle
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
playerView
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class ZoomableContent(
|
||||
val url: String,
|
||||
val description: String? = null,
|
||||
val hash: String? = null
|
||||
)
|
||||
|
||||
class ZoomableImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val bluehash: String? = null
|
||||
) : ZoomableContent(url, description, hash)
|
||||
|
||||
class ZoomableVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null
|
||||
) : ZoomableContent(url, description, hash)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
return if (isImage) {
|
||||
ZoomableImage(fullUrl)
|
||||
} else if (isVideo) {
|
||||
ZoomableVideo(fullUrl)
|
||||
} else {
|
||||
ZoomableImage(fullUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
|
||||
)
|
||||
|
||||
if (content is ZoomableImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = mainImageModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState !is AsyncImagePainter.State.Success) {
|
||||
if (content.bluehash != null) {
|
||||
DisplayBlueHash(content, mainImageModifier)
|
||||
} else {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VideoView(content.url, content.description) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlueHash(
|
||||
content: ZoomableImage,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (content.bluehash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
content.bluehash
|
||||
),
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column() {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
SaveToGallery(url = allImages[pagerState.currentPage].url)
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomableImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState()),
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
if (imageState !is AsyncImagePainter.State.Success) {
|
||||
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
|
||||
} else {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.url, content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||
val imageFile = snapshot.data.toFile()
|
||||
val bytes = imageFile.readBytes()
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(bytes).toHexKey()
|
||||
|
||||
Log.d("Image Hash Verification", "$hash == ${content.hash}")
|
||||
|
||||
return hash == content.hash
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
|
||||
if (verifiedHash == null) return
|
||||
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.width(40.dp)
|
||||
.height(40.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (verifiedHash == true) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.hash_verification_passed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"Hash Verified",
|
||||
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else if (verifiedHash == false) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
localContext,
|
||||
localContext.getString(R.string.hash_verification_failed),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Hash",
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = word,
|
||||
contentDescription = word,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
||||
),
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState !is AsyncImagePainter.State.Success) {
|
||||
ClickableUrl(urlText = "$word ", url = word)
|
||||
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(word) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column() {
|
||||
var pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
SaveToGallery(url = allImages[pagerState.currentPage])
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(imageUrl: String) {
|
||||
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object ChannelFeedFilter : FeedFilter<Note>() {
|
||||
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
lateinit var channel: Channel
|
||||
|
||||
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter<Note>() {
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return collection
|
||||
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object ChatroomFeedFilter : FeedFilter<Note>() {
|
||||
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var withUser: User? = null
|
||||
|
||||
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter<Note>() {
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val myAccount = account
|
||||
val myUser = withUser
|
||||
|
||||
if (myAccount == null || myUser == null) return emptySet()
|
||||
|
||||
val messages = myAccount
|
||||
.userProfile()
|
||||
.privateChatrooms[myUser] ?: return emptySet()
|
||||
|
||||
return collection
|
||||
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val privateChatrooms = me.privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
@@ -4,16 +4,37 @@ import android.util.Log
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
abstract class FeedFilter<T>() {
|
||||
abstract class FeedFilter<T> {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun loadTop(): List<T> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
feed().take(1000)
|
||||
feed()
|
||||
}
|
||||
|
||||
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
|
||||
return feed.take(1000)
|
||||
}
|
||||
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
|
||||
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
|
||||
abstract fun applyFilter(collection: Set<T>): Set<T>
|
||||
abstract fun sort(collection: Set<T>): List<T>
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val newItemsToBeAdded = applyFilter(newItems)
|
||||
if (newItemsToBeAdded.isNotEmpty()) {
|
||||
val newList = oldList.toSet() + newItemsToBeAdded
|
||||
sort(newList).take(1000)
|
||||
} else {
|
||||
oldList
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
}
|
||||
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
|
||||
@@ -3,57 +3,48 @@ 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.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.model.*
|
||||
|
||||
object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val followChannels = account.followingChannels()
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val followChannels = account.followingChannels
|
||||
val followUsers = account.followingKeySet()
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent) &&
|
||||
it.replyTo.isNullOrEmpty()
|
||||
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
val channel = it.channelHex()
|
||||
// does not show events already in the public chat list
|
||||
(it.channel() == null || it.channel() !in followChannels) &&
|
||||
(channel == null || channel !in followChannels) &&
|
||||
// does not show people the user already follows
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
||||
it.createdAt()!! <= System.currentTimeMillis() / 1000
|
||||
it.createdAt()!! <= now
|
||||
}
|
||||
.toList()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
|
||||
}
|
||||
.filter {
|
||||
// does not show events already in the public chat list
|
||||
(it.channel() == null || it.channel() !in followChannels) &&
|
||||
// does not show people the user already follows
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
|
||||
it.createdAt()!! <= System.currentTimeMillis() / 1000
|
||||
}
|
||||
.toList()
|
||||
|
||||
return (notes + longFormNotes)
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HashtagFeedFilter : FeedFilter<Note>() {
|
||||
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var tag: String? = null
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val myTag = tag ?: return emptyList()
|
||||
fun loadHashtag(account: Account, tag: String?) {
|
||||
this.account = account
|
||||
this.tag = tag
|
||||
}
|
||||
|
||||
return LocalCache.notes.values
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val myTag = tag ?: return emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(
|
||||
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter<Note>() {
|
||||
it.event?.isTaggedHash(myTag) == true
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.toList()
|
||||
.reversed()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun loadHashtag(account: Account, tag: String?) {
|
||||
this.account = account
|
||||
this.tag = tag
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,26 +4,37 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
||||
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
return LocalCache.notes.values
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is RepostEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
|
||||
(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) } ?: true &&
|
||||
!it.isNewThread()
|
||||
}
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
return sort(notes + longFormNotes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
|
||||
(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) } ?: true &&
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||
it.isNewThread()
|
||||
}
|
||||
.toSet()
|
||||
}
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it ->
|
||||
(it.event is LongTextNoteEvent) &&
|
||||
(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) } ?: true &&
|
||||
it.isNewThread()
|
||||
}
|
||||
|
||||
return (notes + longFormNotes)
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,52 +3,56 @@ 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.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object NotificationFeedFilter : FeedFilter<Note>() {
|
||||
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val loggedInUser = account.userProfile()
|
||||
return LocalCache.notes.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event !is ChannelCreateEvent &&
|
||||
it.event !is ChannelMetadataEvent &&
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
|
||||
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
|
||||
}
|
||||
.filter { it ->
|
||||
it.event !is TextNoteEvent ||
|
||||
(it.event as? TextNoteEvent)?.taggedEvents()?.any {
|
||||
LocalCache.checkGetOrCreateNote(it)?.author == loggedInUser
|
||||
} == true ||
|
||||
loggedInUser in it.directlyCiteUsers()
|
||||
}
|
||||
.filter { it ->
|
||||
it.event !is PollNoteEvent ||
|
||||
it.replyTo?.any { it.author == account.userProfile() } == true ||
|
||||
account.userProfile() in it.directlyCiteUsers()
|
||||
}
|
||||
.filter {
|
||||
it.event !is ReactionEvent ||
|
||||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
|
||||
loggedInUser in it.directlyCiteUsers()
|
||||
}
|
||||
.filter {
|
||||
it.event !is RepostEvent ||
|
||||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
|
||||
loggedInUser in it.directlyCiteUsers()
|
||||
}
|
||||
.sortedBy { it.createdAt() }
|
||||
.toList()
|
||||
.reversed()
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
fun isDifferentAccount(account: Account): Boolean {
|
||||
return this::account.isInitialized && this.account != account
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val loggedInUser = account.userProfile()
|
||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||
|
||||
return collection.filter {
|
||||
it.event !is ChannelCreateEvent &&
|
||||
it.event !is ChannelMetadataEvent &&
|
||||
it.event !is LnZapRequestEvent &&
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.author !== loggedInUser &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
||||
tagsAnEventByUser(it, loggedInUser)
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedBy { it.createdAt() }.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)
|
||||
}
|
||||
|
||||
if (event is ReactionEvent) {
|
||||
return note.replyTo?.lastOrNull()?.author === author
|
||||
}
|
||||
|
||||
if (event is RepostEvent) {
|
||||
return note.replyTo?.lastOrNull()?.author === author
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
@@ -135,6 +136,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
||||
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
||||
@@ -159,13 +161,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
withContext(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
|
||||
@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
sealed class Route(
|
||||
val route: String,
|
||||
val icon: Int,
|
||||
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
|
||||
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
|
||||
val arguments: List<NamedNavArgument> = emptyList()
|
||||
) {
|
||||
val base: String
|
||||
@@ -30,7 +32,7 @@ sealed class Route(
|
||||
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
|
||||
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||
),
|
||||
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
)
|
||||
|
||||
object Search : Route(
|
||||
@@ -43,13 +45,13 @@ sealed class Route(
|
||||
route = "Notification?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_notifications,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
|
||||
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
)
|
||||
|
||||
object Message : Route(
|
||||
route = "Message",
|
||||
icon = R.drawable.ic_dm,
|
||||
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
|
||||
hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
|
||||
)
|
||||
|
||||
object BlockedUsers : Route(
|
||||
@@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? {
|
||||
return navBackStackEntry?.destination?.route
|
||||
}
|
||||
|
||||
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
val lastTime = cache.load("HomeFollows")
|
||||
open class LatestItem {
|
||||
var newestItemPerAccount: Map<String, Note?> = mapOf()
|
||||
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
|
||||
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
|
||||
return (
|
||||
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
||||
?: 0
|
||||
) > lastTime
|
||||
if (newestItem == null) {
|
||||
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||
account.userProfile().pubkeyHex,
|
||||
filter.feed().firstOrNull { it.createdAt() != null }
|
||||
)
|
||||
} else {
|
||||
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||
account.userProfile().pubkeyHex,
|
||||
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
|
||||
)
|
||||
}
|
||||
|
||||
return newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
val lastTime = cache.load("Notification")
|
||||
object HomeLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
val lastTime = cache.load("HomeFollows")
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
|
||||
|
||||
return (
|
||||
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
||||
?: 0
|
||||
) > lastTime
|
||||
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
}
|
||||
|
||||
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
ChatroomListKnownFeedFilter.account = account
|
||||
object NotificationLatestItem : LatestItem() {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
val lastTime = cache.load("Notification")
|
||||
NotificationFeedFilter.account = account
|
||||
|
||||
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
|
||||
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
|
||||
} ?: return false
|
||||
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
|
||||
|
||||
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
|
||||
|
||||
return (note.createdAt() ?: 0) > lastTime
|
||||
return (newestItem?.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
}
|
||||
|
||||
object MessagesLatestItem {
|
||||
fun hasNewItems(
|
||||
account: Account,
|
||||
cache: NotificationCache,
|
||||
newNotes: Set<Note>
|
||||
): Boolean {
|
||||
ChatroomListKnownFeedFilter.account = account
|
||||
|
||||
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
|
||||
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
|
||||
} ?: return false
|
||||
|
||||
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
|
||||
|
||||
return (note.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,30 +246,32 @@ fun ChatroomMessageCompose(
|
||||
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)} " + (
|
||||
event.channelInfo().name
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + " ${stringResource(R.string.with_description_of)} '" + (
|
||||
event.channelInfo().about
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture)} '" + (
|
||||
event.channelInfo().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)} '" + (
|
||||
event.channelInfo().name
|
||||
channelInfo.name
|
||||
?: ""
|
||||
) + "$', {stringResource(R.string.description_to)} '" + (
|
||||
event.channelInfo().about
|
||||
channelInfo.about
|
||||
?: ""
|
||||
) + "', ${stringResource(R.string.and_picture_to)} '" + (
|
||||
event.channelInfo().picture
|
||||
channelInfo.picture
|
||||
?: ""
|
||||
) + "'"
|
||||
)
|
||||
@@ -371,6 +373,7 @@ private fun RelayBadges(baseNote: Note) {
|
||||
) {
|
||||
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) }),
|
||||
|
||||
@@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard) {
|
||||
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew =
|
||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
if (noteEvent is ChannelMessageEvent) {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == accountViewModel.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
|
||||
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
|
||||
@@ -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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -33,9 +34,13 @@ import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
@@ -59,7 +64,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard) {
|
||||
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
@@ -78,14 +83,31 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
if (noteEvent is ChannelMessageEvent) {
|
||||
note
|
||||
.channel()
|
||||
?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == accountViewModel.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
|
||||
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
@@ -118,7 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
|
||||
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +161,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.boostEvents, navController, account)
|
||||
AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +182,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.likeEvents, navController, account)
|
||||
AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,10 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: Collection<Note>,
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: Map<Note, Note>,
|
||||
navController: NavController,
|
||||
account: Account
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
@@ -202,12 +225,95 @@ fun AuthorGallery(
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
FastNoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = zapRequest.author ?: return
|
||||
|
||||
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
|
||||
|
||||
LaunchedEffect(key1 = zapRequest.idHex) {
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(author, decryptedContent.content)
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank()) {
|
||||
content = Pair(author, zapRequest.event?.content())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
author: User,
|
||||
comment: String?,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val modifier = if (!comment.isNullOrBlank()) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
FastNoteAuthorPicture(
|
||||
author = author,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
|
||||
if (!comment.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
TranslatableRichTextViewer(
|
||||
content = comment,
|
||||
canPreview = true,
|
||||
tags = null,
|
||||
modifier = Modifier.weight(1f),
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: Collection<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,20 +321,16 @@ fun AuthorGallery(
|
||||
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
note: Note,
|
||||
author: User,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
// can't be null if here
|
||||
val author = note.author ?: return
|
||||
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
|
||||
@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
||||
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Column(modifier = columnModifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05.split("@")[0] != "_") {
|
||||
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
||||
)
|
||||
}
|
||||
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
|
||||
@@ -5,8 +5,6 @@ import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -61,11 +59,10 @@ import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun NoteCompose(
|
||||
baseNote: Note,
|
||||
@@ -96,7 +93,7 @@ fun NoteCompose(
|
||||
)
|
||||
}
|
||||
|
||||
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
|
||||
Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -116,6 +113,7 @@ fun NoteComposeInner(
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val loggedIn = account.userProfile()
|
||||
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
@@ -130,6 +128,19 @@ fun NoteComposeInner(
|
||||
|
||||
var moreActionsExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
var isAcceptable by remember { mutableStateOf(true) }
|
||||
var canPreview by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(key1 = noteReportsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
canPreview = note?.author === loggedIn ||
|
||||
(note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
isAcceptable = account.isAcceptable(noteForReports)
|
||||
}
|
||||
}
|
||||
|
||||
val noteEvent = note?.event
|
||||
val baseChannel = note?.channel()
|
||||
|
||||
@@ -141,11 +152,11 @@ fun NoteComposeInner(
|
||||
),
|
||||
isBoostedNote
|
||||
)
|
||||
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
|
||||
} else if (!isAcceptable && !showHiddenNote) {
|
||||
if (!account.isHidden(noteForReports.author!!)) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
loggedIn,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
navController,
|
||||
@@ -156,6 +167,8 @@ fun NoteComposeInner(
|
||||
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||
BadgeDisplay(baseNote = note)
|
||||
} else if (noteEvent is FileHeaderEvent) {
|
||||
FileHeaderDisplay(note)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
@@ -193,7 +206,20 @@ fun NoteComposeInner(
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
navController.navigate("Room/${note.author?.pubkeyHex}")
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == accountViewModel.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
|
||||
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}")
|
||||
}
|
||||
@@ -218,7 +244,7 @@ fun NoteComposeInner(
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
|
||||
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
|
||||
|
||||
if (noteEvent is RepostEvent) {
|
||||
note.replyTo?.lastOrNull()?.let {
|
||||
@@ -231,7 +257,7 @@ fun NoteComposeInner(
|
||||
NoteAuthorPicture(
|
||||
it,
|
||||
navController,
|
||||
account.userProfile(),
|
||||
loggedIn,
|
||||
35.dp,
|
||||
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
|
||||
)
|
||||
@@ -272,7 +298,7 @@ fun NoteComposeInner(
|
||||
}
|
||||
|
||||
if (noteEvent is RepostEvent) {
|
||||
note.replyTo?.lastOrNull()?.let {
|
||||
baseNote.replyTo?.lastOrNull()?.let {
|
||||
RelayBadges(it)
|
||||
}
|
||||
} else {
|
||||
@@ -286,7 +312,7 @@ fun NoteComposeInner(
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isQuotedNote) {
|
||||
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
|
||||
NoteAuthorPicture(note, navController, loggedIn, 25.dp)
|
||||
Spacer(Modifier.padding(horizontal = 5.dp))
|
||||
NoteUsernameDisplay(note, Modifier.weight(1f))
|
||||
} else {
|
||||
@@ -310,7 +336,7 @@ fun NoteComposeInner(
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
modifier = Modifier.size(24.dp),
|
||||
onClick = { moreActionsExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
@@ -343,18 +369,13 @@ fun NoteComposeInner(
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
|
||||
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
|
||||
val sortedMentions = noteEvent.mentions()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowingCached(it) }
|
||||
|
||||
val replyingDirectlyTo = note.replyTo?.lastOrNull()
|
||||
if (replyingDirectlyTo != null && unPackReply) {
|
||||
NoteCompose(
|
||||
baseNote = replyingDirectlyTo,
|
||||
isQuotedNote = true,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.padding(top = 5.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
@@ -369,14 +390,14 @@ fun NoteComposeInner(
|
||||
navController = navController
|
||||
)
|
||||
} else {
|
||||
ReplyInformation(note.replyTo, sortedMentions, account, navController)
|
||||
ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
|
||||
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
|
||||
val sortedMentions = noteEvent.mentions()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowingCached(it) }
|
||||
.sortedBy { loggedIn.isFollowingCached(it) }
|
||||
|
||||
note.channel()?.let {
|
||||
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
|
||||
@@ -427,9 +448,9 @@ fun NoteComposeInner(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
} else if (noteEvent is LongTextNoteEvent) {
|
||||
LongFormHeader(noteEvent, note, account.userProfile())
|
||||
LongFormHeader(noteEvent, note, loggedIn)
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel, navController)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
@@ -445,7 +466,7 @@ fun NoteComposeInner(
|
||||
UserPicture(
|
||||
user = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
userAccount = loggedIn,
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
@@ -464,23 +485,23 @@ fun NoteComposeInner(
|
||||
)
|
||||
}
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel, navController)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
} else if (noteEvent is PrivateDmEvent &&
|
||||
noteEvent.recipientPubKey() != account.userProfile().pubkeyHex &&
|
||||
note.author != account.userProfile()
|
||||
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
|
||||
note.author !== loggedIn
|
||||
) {
|
||||
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
|
||||
val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
stringResource(
|
||||
id = R.string.private_conversation_notification,
|
||||
"@${note.author?.pubkeyNpub()}",
|
||||
"@${recepient?.pubkeyNpub()}"
|
||||
"@${recipient?.pubkeyNpub()}"
|
||||
),
|
||||
canPreview = !makeItShort,
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -491,7 +512,7 @@ fun NoteComposeInner(
|
||||
)
|
||||
|
||||
if (!makeItShort) {
|
||||
ReactionsRow(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel, navController)
|
||||
}
|
||||
|
||||
Divider(
|
||||
@@ -501,12 +522,8 @@ fun NoteComposeInner(
|
||||
} else {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
val canPreview = note.author == account.userProfile() ||
|
||||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
if (makeItShort && note.author == account.userProfile()) {
|
||||
if (makeItShort && note.author == loggedIn) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
@@ -524,7 +541,7 @@ fun NoteComposeInner(
|
||||
navController
|
||||
)
|
||||
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, navController)
|
||||
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
|
||||
}
|
||||
|
||||
if (noteEvent is PollNoteEvent) {
|
||||
@@ -539,7 +556,7 @@ fun NoteComposeInner(
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
ReactionsRow(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel, navController)
|
||||
}
|
||||
|
||||
Divider(
|
||||
@@ -561,11 +578,17 @@ fun DisplayFollowingHashtagsInPost(
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
var firstTag by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = noteEvent) {
|
||||
withContext(Dispatchers.IO) {
|
||||
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val firstTag =
|
||||
noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||
if (firstTag != null) {
|
||||
firstTag?.let {
|
||||
ClickableText(
|
||||
text = AnnotatedString(" #$firstTag"),
|
||||
onClick = { navController.navigate("Hashtag/$firstTag") },
|
||||
@@ -582,11 +605,10 @@ fun DisplayFollowingHashtagsInPost(
|
||||
|
||||
@Composable
|
||||
fun DisplayUncitedHashtags(
|
||||
noteEvent: EventInterface,
|
||||
hashtags: List<String>,
|
||||
eventContent: String,
|
||||
navController: NavController
|
||||
) {
|
||||
val hashtags = noteEvent.hashtags()
|
||||
if (hashtags.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
@@ -756,6 +778,34 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileHeaderDisplay(note: Note) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
|
||||
var content by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = event.id) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val blurHash = event.blurhash()
|
||||
val hash = event.hash()
|
||||
val description = event.content
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
content = if (isImage) {
|
||||
ZoomableImage(fullUrl, description, hash, blurHash)
|
||||
} else {
|
||||
ZoomableVideo(fullUrl, description, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content?.let {
|
||||
ZoomableContentView(content = it, listOf(it))
|
||||
} ?: UrlPreview(fullUrl, "$fullUrl ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
||||
Row(
|
||||
@@ -848,63 +898,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
|
||||
@Composable
|
||||
private fun RelayBadges(baseNote: Note) {
|
||||
val noteRelaysState by baseNote.live().relays.observeAsState()
|
||||
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
|
||||
val noteRelays = noteRelaysState?.note ?: return
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var showShowMore by remember { mutableStateOf(false) }
|
||||
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
|
||||
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
|
||||
LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val relayList = noteRelays.relays.map {
|
||||
it.removePrefix("wss://").removePrefix("ws://")
|
||||
}
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
|
||||
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (shouldListChange) {
|
||||
lazyRelayList = relaysToDisplay
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
|
||||
modifier = Modifier.height(height),
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
items(relaysToDisplay.size) {
|
||||
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
|
||||
|
||||
Box(Modifier.padding(1.dp).size(15.dp)) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
model = "https://$url/favicon.ico",
|
||||
contentDescription = stringResource(R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.width(13.dp)
|
||||
.height(13.dp)
|
||||
.clip(shape = CircleShape)
|
||||
// .border(1.dp, Color.Red)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
val nextShowMore = relayList.size > 3 && !expanded
|
||||
if (nextShowMore != showShowMore) {
|
||||
// only triggers recomposition when actually different
|
||||
showShowMore = nextShowMore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noteRelays.size > 3 && !expanded) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(25.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.Top
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
VerticalRelayPanelWithFlow(lazyRelayList)
|
||||
|
||||
if (showShowMore) {
|
||||
ShowMoreRelaysButton {
|
||||
expanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun VerticalRelayPanelWithFlow(
|
||||
relays: List<String>
|
||||
) {
|
||||
// FlowRow Seems to be a lot faster than LazyVerticalGrid
|
||||
FlowRow() {
|
||||
relays.forEach { url ->
|
||||
RelayIconCompose(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Stable
|
||||
private fun RelayIconCompose(url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(1.dp)
|
||||
.size(15.dp)
|
||||
) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
robotSize = 15.dp,
|
||||
model = "https://$url/favicon.ico",
|
||||
contentDescription = stringResource(R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.width(13.dp)
|
||||
.height(13.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(25.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = onClick
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { expanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.ExpandMore,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -943,6 +1032,7 @@ fun NoteAuthorPicture(
|
||||
if (author == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = "authornotfound",
|
||||
robotSize = size,
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = modifier
|
||||
.width(size)
|
||||
@@ -1062,6 +1152,13 @@ fun UserPicture(
|
||||
}
|
||||
}
|
||||
|
||||
data class DropDownParams(
|
||||
val isFollowingAuthor: Boolean,
|
||||
val isPrivateBookmarkNote: Boolean,
|
||||
val isPublicBookmarkNote: Boolean,
|
||||
val isLoggedUser: Boolean
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
@@ -1069,11 +1166,28 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
val actContext = LocalContext.current
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf<DropDownParams>(
|
||||
DropDownParams(false, false, false, false)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
state = DropDownParams(
|
||||
accountViewModel.isFollowing(note.author),
|
||||
accountViewModel.isInPrivateBookmarks(note),
|
||||
accountViewModel.isInPublicBookmarks(note),
|
||||
accountViewModel.isLoggedUser(note.author)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
if (!accountViewModel.isFollowing(note.author)) {
|
||||
if (!state.isFollowingAuthor) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.follow(
|
||||
note.author ?: return@DropdownMenuItem
|
||||
@@ -1086,10 +1200,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_text))
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")); onDismiss() }) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_user_pubkey))
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_note_id))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
@@ -1110,7 +1224,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Text(stringResource(R.string.quick_action_share))
|
||||
}
|
||||
Divider()
|
||||
if (accountViewModel.isInPrivateBookmarks(note)) {
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.remove_from_private_bookmarks))
|
||||
}
|
||||
@@ -1119,7 +1233,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Text(stringResource(R.string.add_to_private_bookmarks))
|
||||
}
|
||||
}
|
||||
if (accountViewModel.isInPublicBookmarks(note)) {
|
||||
if (state.isPublicBookmarkNote) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.remove_from_public_bookmarks))
|
||||
}
|
||||
@@ -1133,7 +1247,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Text(stringResource(R.string.broadcast))
|
||||
}
|
||||
Divider()
|
||||
if (accountViewModel.isLoggedUser(note.author)) {
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.request_deletion))
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ val externalLinkForNote = { note: Note ->
|
||||
"https://habla.news/a/${note.address().toNAddr()}"
|
||||
}
|
||||
} else {
|
||||
"https://snort.social/e/${note.idNote()}"
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,6 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
var showDeleteAlertDialog by remember { mutableStateOf(false) }
|
||||
var showBlockAlertDialog by remember { mutableStateOf(false) }
|
||||
var showReportDialog by remember { mutableStateOf(false) }
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
|
||||
|
||||
val backgroundColor = if (MaterialTheme.colors.isLight) {
|
||||
MaterialTheme.colors.primary
|
||||
@@ -129,6 +127,9 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
}
|
||||
|
||||
if (popupExpanded) {
|
||||
val isOwnNote = accountViewModel.isLoggedUser(note.author)
|
||||
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
|
||||
|
||||
Popup(onDismissRequest = onDismiss) {
|
||||
Card(
|
||||
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
|
||||
@@ -151,13 +152,13 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
|
||||
clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}"))
|
||||
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
|
||||
showToast(R.string.copied_user_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
|
||||
clipboardManager.setText(AnnotatedString("@${note.idNote()}"))
|
||||
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
|
||||
showToast(R.string.copied_note_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -47,111 +49,153 @@ fun PollNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.account = account
|
||||
pollViewModel.load(zappedNote)
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
optionTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
}
|
||||
OptionNote(
|
||||
poll_op.key,
|
||||
poll_op.value,
|
||||
pollViewModel,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile()) == true) {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
poll_op.key,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
color,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = color,
|
||||
progress = optionTally
|
||||
@Composable
|
||||
private fun OptionNote(
|
||||
optionNumber: Int,
|
||||
optionText: String,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
navController: NavController
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (!pollViewModel.canZap()) {
|
||||
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
|
||||
|
||||
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
|
||||
val myTally = pollViewModel.optionVoteTally(optionNumber)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
myTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
|
||||
if (myTally > optionTally.first || color != optionTally.second) {
|
||||
optionTally = Pair(myTally, color)
|
||||
}
|
||||
}
|
||||
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
optionTally.second,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = optionTally.second,
|
||||
progress = optionTally.first.toFloat()
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(40.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${(optionTally * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
|
||||
TranslatableRichTextViewer(
|
||||
poll_op.value,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(15.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
clickablePrepend = {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
poll_op.key,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colors.primary,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
poll_op.value,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
},
|
||||
clickablePrepend = {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colors.primary,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +253,16 @@ fun ZapVote(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
context.getString(R.string.poll_author_no_vote),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
|
||||
// only allow one vote per option when min==max, i.e. atomic vote amount specified
|
||||
scope.launch {
|
||||
@@ -241,7 +295,8 @@ fun ZapVote(
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
}
|
||||
},
|
||||
zapType = account.defaultZapType
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -279,7 +334,17 @@ fun ZapVote(
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
|
||||
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!optionWasZappedByLoggedInUser) {
|
||||
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionWasZappedByLoggedInUser) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -306,8 +371,18 @@ fun ZapVote(
|
||||
}
|
||||
}
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
|
||||
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
|
||||
Text(
|
||||
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
|
||||
fontSize = 14.sp,
|
||||
@@ -368,7 +443,8 @@ fun FilteredZapAmountChoicePopup(
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
onProgress,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -393,7 +469,8 @@ fun FilteredZapAmountChoicePopup(
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
onProgress,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.*
|
||||
|
||||
class PollNoteViewModel {
|
||||
@@ -16,7 +17,9 @@ class PollNoteViewModel {
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
private var closedAt: Int? = null
|
||||
var consensusThreshold: Float? = null
|
||||
var consensusThreshold: BigDecimal? = null
|
||||
|
||||
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
|
||||
fun load(note: Note?) {
|
||||
pollNote = note
|
||||
@@ -24,8 +27,17 @@ class PollNoteViewModel {
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
|
||||
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
|
||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)
|
||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
|
||||
totalZapped = totalZapped()
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !note.isZappedBy(user, account)
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
@@ -73,47 +85,43 @@ class PollNoteViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
fun optionVoteTally(op: Int): Float {
|
||||
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal())
|
||||
return if (tally.isNaN()) { // catch div by 0
|
||||
0f
|
||||
} else { tally }
|
||||
}
|
||||
|
||||
private fun zappedVoteTotal(): Float {
|
||||
var total = 0f
|
||||
pollOptions?.keys?.forEach {
|
||||
total += zappedPollOptionAmount(it).toFloat()
|
||||
fun optionVoteTally(op: Int): BigDecimal {
|
||||
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
if (pollNote?.zaps?.any { it.key.author == user } == true) {
|
||||
pollNote!!.zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.map {
|
||||
val zappedOption = it.zappedPollOption()
|
||||
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return pollNote!!.zaps
|
||||
.any {
|
||||
val zapEvent = it.value?.event as? LnZapEvent
|
||||
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
|
||||
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
return if (pollNote != null) {
|
||||
pollNote!!.zaps.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
val zappedOption = it.zappedPollOption()
|
||||
if (zappedOption == option) {
|
||||
it.amount
|
||||
} else { null }
|
||||
}.sumOf { it }
|
||||
} else {
|
||||
BigDecimal(0)
|
||||
}
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val event = it?.event as? LnZapEvent
|
||||
if (event?.zappedPollOption() == option) {
|
||||
event.amount ?: BigDecimal(0)
|
||||
} else {
|
||||
BigDecimal(0)
|
||||
}
|
||||
} ?: BigDecimal(0)
|
||||
}
|
||||
|
||||
fun totalZapped(): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val zapEvent = (it?.event as? LnZapEvent)
|
||||
|
||||
if (zapEvent?.zappedPollOption() != null) {
|
||||
zapEvent.amount ?: BigDecimal(0)
|
||||
} else {
|
||||
BigDecimal(0)
|
||||
}
|
||||
} ?: BigDecimal(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -50,6 +49,7 @@ import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
@@ -63,9 +63,10 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -78,33 +79,35 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
}
|
||||
|
||||
if (wantsToReplyTo != null) {
|
||||
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account)
|
||||
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
|
||||
}
|
||||
|
||||
if (wantsToQuote != null) {
|
||||
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
|
||||
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
|
||||
wantsToReplyTo = baseNote
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ReplyReaction(baseNote, accountViewModel) {
|
||||
wantsToReplyTo = baseNote
|
||||
}
|
||||
}
|
||||
|
||||
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
|
||||
wantsToQuote = baseNote
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
BoostReaction(baseNote, accountViewModel) {
|
||||
wantsToQuote = baseNote
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
LikeReaction(baseNote, accountViewModel)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ZapReaction(baseNote, accountViewModel)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ViewCountReaction(baseNote.idHex)
|
||||
}
|
||||
|
||||
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
|
||||
|
||||
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
|
||||
|
||||
ViewCountReaction(baseNote, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,14 +115,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
fun ReplyReaction(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
textModifier: Modifier = Modifier,
|
||||
showCounter: Boolean = true,
|
||||
onPress: () -> Unit
|
||||
) {
|
||||
val repliesState by baseNote.live().replies.observeAsState()
|
||||
val replies = repliesState?.note?.replies ?: emptySet()
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -139,29 +140,32 @@ fun ReplyReaction(
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
ReplyIcon()
|
||||
}
|
||||
|
||||
if (showCounter) {
|
||||
Text(
|
||||
" ${showCount(replies.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint,
|
||||
modifier = textModifier
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyIcon() {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoostReaction(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
textModifier: Modifier = Modifier,
|
||||
onQuotePress: () -> Unit
|
||||
) {
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
@@ -227,16 +231,14 @@ private fun BoostReaction(
|
||||
Text(
|
||||
" ${showCount(boostedNote?.boosts?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = textModifier
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeReaction(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
textModifier: Modifier = Modifier
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
val reactedNote = reactionsState?.note ?: return
|
||||
@@ -285,8 +287,7 @@ fun LikeReaction(
|
||||
Text(
|
||||
" ${showCount(reactedNote.reactions.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = textModifier
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,6 +314,16 @@ fun ZapReaction(
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -362,7 +373,8 @@ fun ZapReaction(
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
}
|
||||
},
|
||||
zapType = account.defaultZapType
|
||||
)
|
||||
}
|
||||
} else if (account.zapAmountChoices.size > 1) {
|
||||
@@ -410,7 +422,7 @@ fun ZapReaction(
|
||||
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
|
||||
}
|
||||
|
||||
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
|
||||
if (wasZappedByLoggedInUser) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -454,13 +466,13 @@ fun ZapReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
|
||||
private fun ViewCountReaction(idHex: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(20.dp),
|
||||
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
||||
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BarChart,
|
||||
@@ -470,10 +482,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = textModifier) {
|
||||
Row() {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
|
||||
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build(),
|
||||
@@ -558,7 +570,8 @@ fun ZapAmountChoicePopup(
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
onProgress,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -583,7 +596,8 @@ fun ZapAmountChoicePopup(
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
onProgress,
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -604,9 +618,9 @@ fun showCount(count: Int?): String {
|
||||
if (count == 0) return ""
|
||||
|
||||
return when {
|
||||
count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
|
||||
count >= 1000000 -> "${Math.round(count / 1000000f)}M"
|
||||
count >= 1000 -> "${Math.round(count / 1000f)}k"
|
||||
count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
|
||||
count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
|
||||
count >= 1000 -> "${(count / 1000f).roundToInt()}k"
|
||||
else -> "$count"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,13 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
|
||||
@Composable
|
||||
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Account, navController: NavController) {
|
||||
ReplyInformation(replyTo, mentions, account) {
|
||||
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
|
||||
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
ReplyInformation(replyTo, dupMentions, account) {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
|
||||
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
@@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.selectedZapType = account.defaultZapType
|
||||
}
|
||||
|
||||
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
|
||||
@@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
fun sendPost() {
|
||||
account?.changeZapAmounts(amountSet)
|
||||
account?.changeDefaultZapType(selectedZapType)
|
||||
|
||||
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
|
||||
val pubkeyHex = try {
|
||||
@@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return (
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
selectedZapType != account?.defaultZapType ||
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
|
||||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
|
||||
@@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
val postViewModel: UpdateZapAmountViewModel = viewModel()
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val zapTypes = listOf(
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = zapTypes.map { it.second }
|
||||
val zapOptionExplainers = zapTypes.map { it.third }
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
if (nip47uri != null) {
|
||||
@@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp).imePadding()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"⚡ ${
|
||||
showAmount(
|
||||
amountInSats.toBigDecimal().setScale(1)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"⚡ ${
|
||||
showAmount(
|
||||
amountInSats.toBigDecimal().setScale(1)
|
||||
)
|
||||
} ✖",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} ✖",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.zap_type_explainer),
|
||||
placeholder = zapTypes.filter { it.first == account.defaultZapType }
|
||||
.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedZapType = zapTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service),
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.alby),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
qrScanning = true
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
try {
|
||||
postViewModel.updateNIP47(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
|
||||
value = postViewModel.walletConnectPubkey,
|
||||
onValueChange = {
|
||||
postViewModel.walletConnectPubkey = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectRelay,
|
||||
onValueChange = { postViewModel.walletConnectRelay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "wss://relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
var showPassword by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
showPassword = true
|
||||
}
|
||||
}
|
||||
|
||||
val authTitle =
|
||||
stringResource(id = R.string.wallet_connect_service_show_secret)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectSecret,
|
||||
onValueChange = { postViewModel.walletConnectSecret = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (!showPassword) {
|
||||
authenticate(
|
||||
authTitle,
|
||||
context,
|
||||
scope,
|
||||
keyguardLauncher
|
||||
) {
|
||||
showPassword = true
|
||||
}
|
||||
} else {
|
||||
showPassword = false
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service),
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.alby),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
qrScanning = true
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
try {
|
||||
postViewModel.updateNIP47(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
|
||||
value = postViewModel.walletConnectPubkey,
|
||||
onValueChange = {
|
||||
postViewModel.walletConnectPubkey = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectRelay,
|
||||
onValueChange = { postViewModel.walletConnectRelay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "wss://relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
var showPassword by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
showPassword = true
|
||||
}
|
||||
}
|
||||
|
||||
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectSecret,
|
||||
onValueChange = { postViewModel.walletConnectSecret = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (!showPassword) {
|
||||
authenticate(authTitle, context, scope, keyguardLauncher) {
|
||||
showPassword = true
|
||||
}
|
||||
} else {
|
||||
showPassword = false
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -27,15 +22,17 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ZapOptionstViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
|
||||
var customAmount by mutableStateOf(TextFieldValue("1000"))
|
||||
var customAmount by mutableStateOf(TextFieldValue("21"))
|
||||
var customMessage by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
fun load(account: Account) {
|
||||
@@ -68,6 +65,19 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
val zapTypes = listOf(
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = zapTypes.map { it.second }
|
||||
val zapOptionExplainers = zapTypes.map { it.third }
|
||||
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
@@ -98,6 +108,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
postViewModel.customMessage.text,
|
||||
context,
|
||||
onError = {
|
||||
zappingProgress = 0f
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
@@ -105,8 +116,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
}
|
||||
},
|
||||
zapType = selectedZapType
|
||||
)
|
||||
}
|
||||
onClose()
|
||||
@@ -139,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.padding(end = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.zap_type),
|
||||
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
selectedZapType = zapTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -153,7 +178,15 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
) {
|
||||
OutlinedTextField(
|
||||
// stringResource(R.string.new_amount_in_sats
|
||||
label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
|
||||
label = {
|
||||
if (selectedZapType == LnZapEvent.ZapType.PUBLIC || selectedZapType == LnZapEvent.ZapType.ANONYMOUS) {
|
||||
Text(text = stringResource(id = R.string.custom_zaps_add_a_message))
|
||||
} else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private))
|
||||
} else if (selectedZapType == LnZapEvent.ZapType.NONZAP) {
|
||||
Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap))
|
||||
}
|
||||
},
|
||||
value = postViewModel.customMessage,
|
||||
onValueChange = {
|
||||
postViewModel.customMessage = it
|
||||
@@ -170,7 +203,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.padding(end = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapSetCard) {
|
||||
LaunchedEffect(key1 = zapSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
@@ -15,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -30,7 +31,7 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
|
||||
|
||||
open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
@@ -46,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
|
||||
@Synchronized
|
||||
private fun refreshSuspended() {
|
||||
val notes = dataSource.loadTop()
|
||||
val notes = localFilter.loadTop()
|
||||
|
||||
val thisAccount = (dataSource as? NotificationFeedFilter)?.account
|
||||
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
||||
|
||||
val oldNotesState = feedContent.value
|
||||
val oldNotesState = _feedContent.value
|
||||
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
|
||||
val newCards = convertToCard(notes.minus(lastNotesCopy))
|
||||
if (newCards.isNotEmpty()) {
|
||||
lastNotes = notes
|
||||
lastAccount = (dataSource as? NotificationFeedFilter)?.account
|
||||
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
|
||||
}
|
||||
} else {
|
||||
val cards = convertToCard(notes)
|
||||
lastNotes = notes
|
||||
lastAccount = (dataSource as? NotificationFeedFilter)?.account
|
||||
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
updateFeed(cards)
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertToCard(notes: List<Note>): List<Card> {
|
||||
private fun convertToCard(notes: Collection<Note>): List<Card> {
|
||||
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
|
||||
notes
|
||||
.filter { it.event is ReactionEvent }
|
||||
@@ -88,6 +89,8 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
if (zappedPost != null) {
|
||||
val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
|
||||
if (zapRequest != null) {
|
||||
// var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event)
|
||||
// zapRequest.event = newZapRequestEvent
|
||||
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
|
||||
}
|
||||
} else {
|
||||
@@ -159,7 +162,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
private fun updateFeed(notes: List<Card>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
val currentState = feedContent.value
|
||||
val currentState = _feedContent.value
|
||||
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { CardFeedState.Empty }
|
||||
@@ -172,6 +175,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshFromOldState(newItems: Set<Note>) {
|
||||
val oldNotesState = _feedContent.value
|
||||
|
||||
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
|
||||
|
||||
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
|
||||
val filteredNewList = localFilter.applyFilter(newItems)
|
||||
val actuallyNew = filteredNewList.minus(lastNotesCopy)
|
||||
|
||||
val newCards = convertToCard(actuallyNew)
|
||||
if (newCards.isNotEmpty()) {
|
||||
lastNotes = lastNotesCopy + newItems
|
||||
lastAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
|
||||
}
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
@@ -181,13 +206,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
|
||||
}
|
||||
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
invalidateData()
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun invalidateInsertData(newItems: Set<Note>) {
|
||||
bundlerInsert.invalidateList(newItems) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
refreshFromOldState(it.flatten().toSet())
|
||||
}
|
||||
Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||
@@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
fun refreshSuspended() {
|
||||
val notes = newListFromDataSource()
|
||||
|
||||
val oldNotesState = feedContent.value
|
||||
val oldNotesState = _feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
// Using size as a proxy for has changed.
|
||||
if (notes != oldNotesState.feed.value) {
|
||||
@@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
private fun updateFeed(notes: List<Note>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
val currentState = feedContent.value
|
||||
val currentState = _feedContent.value
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { FeedState.Empty }
|
||||
} else if (currentState is FeedState.Loaded) {
|
||||
@@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshFromOldState(newItems: Set<Note>) {
|
||||
val oldNotesState = _feedContent.value
|
||||
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
|
||||
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
|
||||
updateFeed(newList)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
}
|
||||
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
invalidateData()
|
||||
fun invalidateInsertData(newItems: Set<Note>) {
|
||||
bundlerInsert.invalidateList(newItems) {
|
||||
refreshFromOldState(it.flatten().toSet())
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
@@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
private fun refreshSuspended() {
|
||||
val notes = dataSource.loadTop()
|
||||
|
||||
val oldNotesState = feedContent.value
|
||||
val oldNotesState = _feedContent.value
|
||||
if (oldNotesState is LnZapFeedState.Loaded) {
|
||||
// Using size as a proxy for has changed.
|
||||
if (notes != oldNotesState.feed.value) {
|
||||
@@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
private fun updateFeed(notes: List<Pair<Note, Note>>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
val currentState = feedContent.value
|
||||
val currentState = _feedContent.value
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { LnZapFeedState.Empty }
|
||||
} else if (currentState is LnZapFeedState.Loaded) {
|
||||
@@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user