mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920ce54fe3 | ||
|
|
c87b246a4e | ||
|
|
b8bc370bc1 | ||
|
|
633be54dd4 | ||
|
|
470281fa87 | ||
|
|
3287196243 | ||
|
|
bb8bb16370 | ||
|
|
4325975686 | ||
|
|
53ec9d777f | ||
|
|
3e6e7d4863 | ||
|
|
81290f2b26 | ||
|
|
4dcf38c492 | ||
|
|
04c1300317 | ||
|
|
70890def82 | ||
|
|
d0eadcf30d | ||
|
|
ae268cc197 | ||
|
|
d6d273e34c | ||
|
|
462c73341f | ||
|
|
aa07fbfc94 | ||
|
|
59d7f7cc70 | ||
|
|
950eefa4e3 | ||
|
|
f2bbdd44a6 | ||
|
|
cb09a26ef5 | ||
|
|
eba14cf9f1 | ||
|
|
13de5973ca | ||
|
|
936f750826 | ||
|
|
f44549de41 | ||
|
|
1c0fcc4ab8 | ||
|
|
fcc6793244 | ||
|
|
dea35552d5 | ||
|
|
b093873eb0 | ||
|
|
e5de2c45de | ||
|
|
70d8d9e2e0 | ||
|
|
dc0fe75b3d | ||
|
|
25a34dd2e8 | ||
|
|
90227e1cdc | ||
|
|
bba45dd0a7 | ||
|
|
fdca0af97b | ||
|
|
795120e932 | ||
|
|
79da9cee01 | ||
|
|
fbbb9b0d72 | ||
|
|
97d56d61d1 | ||
|
|
f3316f53ea | ||
|
|
e3ccf71a84 | ||
|
|
635279d59a | ||
|
|
0c673e91c6 | ||
|
|
3519e20a98 | ||
|
|
7167d57d22 | ||
|
|
86adac95c9 | ||
|
|
cffcc158f5 | ||
|
|
49a8778975 | ||
|
|
dd62d2bae4 | ||
|
|
3c3b6c97c0 | ||
|
|
dabeea43ea | ||
|
|
5b6b6f19e5 | ||
|
|
28370dbc01 | ||
|
|
ab841c6490 | ||
|
|
12ff434a07 | ||
|
|
51432c8c50 | ||
|
|
483e7d673c | ||
|
|
3ce6ce9951 | ||
|
|
84f51dc473 | ||
|
|
5f4886ce52 | ||
|
|
dea6f0ca21 | ||
|
|
01bdc41286 | ||
|
|
8c2e89197e | ||
|
|
712950c2a1 | ||
|
|
95cc2ee589 | ||
|
|
76283463b0 | ||
|
|
5179a821b2 | ||
|
|
974a8011f9 | ||
|
|
771bb3fc27 | ||
|
|
10bd63ca2f | ||
|
|
1fad3d1c8a | ||
|
|
67cc24c4d9 | ||
|
|
4368b34569 | ||
|
|
65540c8a95 | ||
|
|
b801aa6bd1 | ||
|
|
7224ad4b05 | ||
|
|
b043e2da2c | ||
|
|
f8a60203ec | ||
|
|
7f18bc3a88 | ||
|
|
31740d8855 | ||
|
|
8641e0fec5 | ||
|
|
c8f1e13448 | ||
|
|
580cf76577 | ||
|
|
61549ebfd3 | ||
|
|
797d1bb2b4 | ||
|
|
99c16879c1 | ||
|
|
0bf80e88ce | ||
|
|
a71679ef43 | ||
|
|
4bbb34e8c9 | ||
|
|
0e1420a626 | ||
|
|
79782ee8e7 | ||
|
|
81b70780a6 | ||
|
|
277fc5971e | ||
|
|
850bc15f4f | ||
|
|
6d2db65c12 | ||
|
|
89ce6dbab2 | ||
|
|
80bfbf68b4 | ||
|
|
4464247ddb | ||
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
f889ff2547 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 | ||
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
2a552bea4d |
@@ -1,12 +1,12 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023.
|
||||
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
|
||||
@@ -2,6 +2,7 @@ plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -12,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 131
|
||||
versionName "0.37.4"
|
||||
versionCode 152
|
||||
versionName "0.45.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -86,7 +87,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.0'
|
||||
implementation 'androidx.activity:activity-compose:1.7.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -172,6 +173,10 @@ dependencies {
|
||||
// Google services model the translate text
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Automatic memory leak detection
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
|
||||
|
||||
|
||||
68
app/google-services.json
Normal file
68
app/google-services.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "768341258853",
|
||||
"project_id": "amethyst-3057a",
|
||||
"storage_bucket": "amethyst-3057a.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vitorpamplona.amethyst.debug"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CitationTests {
|
||||
val json = """
|
||||
{
|
||||
"content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
|
||||
"created_at": 1683596206,
|
||||
"id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758",
|
||||
"kind": 1,
|
||||
"pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d",
|
||||
"sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3",
|
||||
"",
|
||||
"root"
|
||||
],
|
||||
[
|
||||
"e",
|
||||
"be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc",
|
||||
"",
|
||||
"reply"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://nostr.wine/"
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test
|
||||
fun parseEvent() {
|
||||
val event = Event.fromJson(json, true) as TextNoteEvent
|
||||
|
||||
val expectedCitations = setOf(
|
||||
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168",
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
|
||||
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
)
|
||||
|
||||
assertEquals(expectedCitations, event.citedUsers())
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
|
||||
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.Base64
|
||||
@@ -21,12 +21,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -39,18 +39,17 @@ class ImageUploadTesting {
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -68,12 +67,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -88,4 +87,27 @@ class ImageUploadTesting {
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrFilesDevServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class PrivateZapTests {
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
@@ -127,8 +127,7 @@ class PrivateZapTests {
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
class PushNotificationUtils {
|
||||
fun init(accounts: List<AccountInfo>) {
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -46,6 +47,12 @@
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -63,6 +70,7 @@
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -6,8 +6,10 @@ import android.content.SharedPreferences
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
@@ -46,6 +48,9 @@ private object PrefKeys {
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -197,6 +202,9 @@ object LocalPreferences {
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
@@ -205,7 +213,13 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences(currentAccount()).apply {
|
||||
val acc = loadFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(npub: String?): Account? {
|
||||
encryptedPreferences(npub).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
@@ -217,6 +231,9 @@ object LocalPreferences {
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
@@ -268,7 +285,7 @@ object LocalPreferences {
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
@@ -278,6 +295,9 @@ object LocalPreferences {
|
||||
zapAmountChoices,
|
||||
defaultZapType,
|
||||
defaultFileServer,
|
||||
defaultHomeFollowList,
|
||||
defaultStoriesFollowList,
|
||||
defaultNotificationFollowList,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
|
||||
@@ -38,19 +38,15 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
@@ -33,10 +34,11 @@ object ServiceManager {
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
@@ -61,6 +63,7 @@ object ServiceManager {
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
@@ -72,7 +75,6 @@ object ServiceManager {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
LocalCache.pruneFileStorageEvents(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -36,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set<String> {
|
||||
return codedList
|
||||
}
|
||||
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
val KIND3_FOLLOWS = " All Follows "
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
@@ -48,6 +53,9 @@ class Account(
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -170,20 +178,50 @@ class Account(
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
|
||||
fun isNIP47Author(pubkeyHex: String?): Boolean {
|
||||
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
|
||||
if (privKey == null) return false
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
|
||||
return (pubKey == pubkeyHex)
|
||||
}
|
||||
|
||||
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
|
||||
val myNip47 = zapPaymentRequest ?: return null
|
||||
|
||||
val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
val pubKey = myNip47.pubKeyHex.hexToByteArray()
|
||||
|
||||
if (privKey == null) return null
|
||||
|
||||
return zapResponseEvent.response(privKey, pubKey)
|
||||
}
|
||||
|
||||
fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
|
||||
return zappedNote?.isZappedBy(userProfile(), this) == true
|
||||
}
|
||||
|
||||
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
|
||||
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray()
|
||||
return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let { nip47 ->
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
|
||||
wcListener.start()
|
||||
|
||||
LocalCache.consume(event) {
|
||||
LocalCache.consume(event, zappedNote) {
|
||||
// After the response is received.
|
||||
val privKey = nip47.secret?.toByteArray()
|
||||
val privKey = nip47.secret?.hexToByteArray()
|
||||
if (privKey != null) {
|
||||
onResponse(it.response(privKey, event.pubKey.toByteArray()))
|
||||
onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,12 +414,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
|
||||
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val data = FileStorageEvent.create(
|
||||
mimeType = headerInfo.mimeType ?: "",
|
||||
data = data,
|
||||
data = byteArray,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -390,16 +428,23 @@ class Account(
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
return Pair(data, signedEvent)
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data)
|
||||
LocalCache.consume(data)
|
||||
LocalCache.consume(data, null)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
@@ -412,13 +457,14 @@ class Account(
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
|
||||
return LocalCache.notes[signedEvent.id]
|
||||
}
|
||||
@@ -694,6 +740,24 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
defaultHomeFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
defaultStoriesFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
defaultNotificationFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -706,6 +770,37 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun selectedUsersFollowList(listName: String?): Set<String>? {
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
|
||||
|
||||
val privKey = loggedIn.privKey
|
||||
|
||||
return if (listName != null) {
|
||||
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
|
||||
val list = LocalCache.addressables[aTag]
|
||||
if (list != null) {
|
||||
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
|
||||
val privateHexList = privKey?.let {
|
||||
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
|
||||
} ?: emptySet()
|
||||
|
||||
(publicHexList + privateHexList).toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedTagsFollowList(listName: String?): Set<String>? {
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
|
||||
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -732,13 +827,13 @@ class Account(
|
||||
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
|
||||
var pubkeyToUse = event.pubKey
|
||||
|
||||
val recepientPK = event.recipientPubKey()
|
||||
val recepientPK = event.verifiedRecipientPubKey()
|
||||
|
||||
if (note.author == userProfile() && recepientPK != null) {
|
||||
pubkeyToUse = recepientPK
|
||||
}
|
||||
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
|
||||
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
@@ -759,7 +854,7 @@ class Account(
|
||||
val privateKeyToUse = loggedInPrivateKey
|
||||
val pubkeyToUse = event.pubKey
|
||||
|
||||
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
|
||||
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
|
||||
} else {
|
||||
// if the sender is logged in, these are the params
|
||||
val altPubkeyToUse = recipientPK
|
||||
@@ -780,11 +875,21 @@ class Account(
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
|
||||
if (result == null) {
|
||||
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -932,14 +1037,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
init {
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerObservers() {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -966,6 +1064,15 @@ class Account(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
backupContactList?.let {
|
||||
println("Loading saved contacts ${it.toJson()}")
|
||||
if (userProfile().latestContactList == null) {
|
||||
LocalCache.consume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
|
||||
|
||||
@@ -55,19 +55,15 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class AntiSpamState(val cache: AntiSpamFilter)
|
||||
|
||||
@@ -74,9 +74,9 @@ class Channel(val idHex: String) {
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleChannelDataSource.add(channel.idHex)
|
||||
|
||||
@@ -14,7 +14,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.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))
|
||||
"grownostr", "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
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.toByteArray(): ByteArray {
|
||||
fun HexKey.hexToByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.toByteArray()
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -12,6 +13,9 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -31,7 +35,8 @@ object LocalCache {
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
@@ -154,6 +159,20 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PeopleListEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
@@ -328,7 +347,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?) {
|
||||
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
@@ -338,9 +357,9 @@ object LocalCache {
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event != null) return note
|
||||
|
||||
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
|
||||
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
@@ -355,6 +374,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
fun consume(event: DeletionEvent) {
|
||||
@@ -379,6 +400,7 @@ object LocalCache {
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeZapPayment(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
@@ -387,7 +409,7 @@ object LocalCache {
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
|
||||
if (recipient != null && author != null) {
|
||||
author.removeMessage(recipient, deleteNote)
|
||||
@@ -653,41 +675,91 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileHeaderEvent) {
|
||||
fun consume(event: FileHeaderEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageHeaderEvent) {
|
||||
fun consume(event: FileStorageHeaderEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageEvent) {
|
||||
fun consume(event: HighlightEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
// Adds to user profile
|
||||
author.addNote(note)
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: FileStorageEvent, relay: Relay?) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
|
||||
if (!file.exists()) {
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
note.loadEvent(eventNoData, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -696,12 +768,41 @@ object LocalCache {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
awaitingPaymentRequests.put(event.id, onResponse)
|
||||
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val responseCallback = awaitingPaymentRequests[event.requestId()]
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
requestNote?.let { request ->
|
||||
zappedNote?.addZapPayment(request, note)
|
||||
}
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
@@ -749,14 +850,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneFileStorageEvents(account: Account) {
|
||||
val toBeRemoved = notes.filter { it.value.event is FileStorageEvent }
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.key)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
@@ -820,12 +913,66 @@ object LocalCache {
|
||||
private fun refreshObservers(newNote: Note) {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
|
||||
fun consume(event: Event, relay: Relay?) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
is BadgeProfilesEvent -> consume(event)
|
||||
is BookmarkListEvent -> consume(event)
|
||||
is ChannelCreateEvent -> consume(event)
|
||||
is ChannelHideMessageEvent -> consume(event)
|
||||
is ChannelMessageEvent -> consume(event, relay)
|
||||
is ChannelMetadataEvent -> consume(event)
|
||||
is ChannelMuteUserEvent -> consume(event)
|
||||
is ContactListEvent -> consume(event)
|
||||
is DeletionEvent -> consume(event)
|
||||
|
||||
is FileHeaderEvent -> consume(event, relay)
|
||||
is FileStorageEvent -> consume(event, relay)
|
||||
is FileStorageHeaderEvent -> consume(event, relay)
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
consume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> consume(event)
|
||||
is LnZapPaymentRequestEvent -> consume(event)
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PeopleListEvent -> consume(event)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is ReportEvent -> consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
consume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is PollNoteEvent -> consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
@@ -156,6 +159,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -174,6 +188,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
@@ -204,6 +229,14 @@ open class Note(val idHex: String) {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +266,44 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
@@ -345,9 +410,9 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +420,6 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
if (note is AddressableNote) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
@@ -181,7 +181,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
@@ -396,9 +396,9 @@ class UserMetadata {
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,10 +406,6 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleUserDataSource.add(user)
|
||||
|
||||
@@ -59,7 +59,6 @@ object BlurHashRequester {
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -14,24 +14,41 @@ class FileHeader(
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val blurHash = if (mimeType?.startsWith("image/") == true) {
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
@@ -47,20 +64,22 @@ class FileHeader(
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${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())
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
|
||||
} else {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -96,12 +96,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -15,13 +15,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val latestEOSEs = EOSEAccount()
|
||||
val chatRoomList = "ChatroomList"
|
||||
|
||||
fun createMessagesToMeFilter() = TypedFilter(
|
||||
types = setOf(FeedType.PRIVATE_DMS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(PrivateDmEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -39,7 +40,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,7 +49,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList(),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
|
||||
)
|
||||
)
|
||||
|
||||
@@ -72,7 +73,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
@@ -80,7 +81,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
}
|
||||
|
||||
val chatroomListChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
@@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in subscriptions.keys) {
|
||||
if (!event.hasValidSignature()) return
|
||||
|
||||
val key = "$debugName $subscriptionId ${event.kind}"
|
||||
val keyValue = eventCounter.get(key)
|
||||
if (keyValue != null) {
|
||||
@@ -62,49 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is BookmarkListEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
is ChannelMetadataEvent -> LocalCache.consume(event)
|
||||
is ChannelMuteUserEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
|
||||
is FileHeaderEvent -> LocalCache.consume(event)
|
||||
is FileStorageEvent -> LocalCache.consume(event)
|
||||
is FileStorageHeaderEvent -> LocalCache.consume(event)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is LnZapRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
|
||||
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is PollNoteEvent -> LocalCache.consume(event, relay)
|
||||
else -> {
|
||||
Log.w("Event Not Supported", event.toJson())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
@@ -44,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.followingKeySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.substring(0, 6)
|
||||
@@ -55,36 +56,36 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.followingTagSet()
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
|
||||
@@ -79,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapRequestEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
PollNoteEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -117,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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.COMMON_FEED_TYPES
|
||||
@@ -10,13 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
}
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
@@ -35,7 +29,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
@@ -88,7 +82,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Tlv
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -14,7 +14,7 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela
|
||||
|
||||
fun toNAddr(): String {
|
||||
val kind = kind.toByteArray()
|
||||
val author = pubKeyHex.toByteArray()
|
||||
val author = pubKeyHex.hexToByteArray()
|
||||
val dTag = dTag.toByteArray(Charsets.UTF_8)
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
|
||||
@@ -10,13 +10,9 @@ class BadgeAwardEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
fun awardees() = taggedUsers()
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
fun awardDefinition() = taggedAddresses()
|
||||
|
||||
companion object {
|
||||
const val kind = 8
|
||||
|
||||
@@ -10,13 +10,13 @@ class BadgeDefinitionEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 30009
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -41,17 +42,18 @@ open class BaseTextNoteEvent(
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
try {
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
if (parsed != null) {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +88,6 @@ open class BaseTextNoteEvent(
|
||||
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 }
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
@@ -15,50 +12,7 @@ class BookmarkListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
@@ -77,24 +31,7 @@ class BookmarkListEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
@@ -38,9 +38,13 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
|
||||
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 taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
|
||||
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
@@ -81,7 +85,7 @@ open class Event(
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -205,6 +209,8 @@ open class Event(
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.registerTypeAdapter(Request::class.java, RequestDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
@@ -227,12 +233,14 @@ open class Event(
|
||||
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.kind -> HighlightEvent(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)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -19,6 +19,7 @@ class FileHeaderEvent(
|
||||
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 dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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)
|
||||
@@ -30,6 +31,7 @@ class FileHeaderEvent(
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
@@ -41,6 +43,7 @@ class FileHeaderEvent(
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
@@ -53,6 +56,7 @@ class FileHeaderEvent(
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
|
||||
@@ -20,6 +20,7 @@ class FileStorageHeaderEvent(
|
||||
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 dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.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)
|
||||
@@ -30,6 +31,7 @@ class FileStorageHeaderEvent(
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
@@ -41,6 +43,7 @@ class FileStorageHeaderEvent(
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
@@ -53,6 +56,7 @@ class FileStorageHeaderEvent(
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import nostr.postr.Utils
|
||||
|
||||
abstract class GeneralListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createPrivateTags(
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
pubKey: ByteArray
|
||||
): String {
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
return Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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 HighlightEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun inUrl() = taggedUrls().firstOrNull()
|
||||
fun author() = taggedUsers().firstOrNull()
|
||||
fun quote() = content
|
||||
|
||||
companion object {
|
||||
const val kind = 9802
|
||||
|
||||
fun create(
|
||||
msg: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PollNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class LnZapEvent(
|
||||
return content
|
||||
}
|
||||
|
||||
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
class LnZapPaymentRequestEvent(
|
||||
@@ -16,13 +21,27 @@ class LnZapPaymentRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
// Once one of an app user decrypts the payment, all users else can see it.
|
||||
@Transient
|
||||
private var lnInvoice: String? = null
|
||||
|
||||
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())
|
||||
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
|
||||
if (lnInvoice != null) {
|
||||
return lnInvoice
|
||||
}
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
|
||||
|
||||
val jsonText = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
|
||||
|
||||
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
|
||||
|
||||
return lnInvoice
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
@@ -39,12 +58,12 @@ class LnZapPaymentRequestEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
|
||||
|
||||
val content = Utils.encrypt(
|
||||
serializedRequest,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
walletServicePubkey.hexToByteArray()
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -59,11 +78,37 @@ class LnZapPaymentRequestEvent(
|
||||
|
||||
// REQUEST OBJECTS
|
||||
|
||||
abstract class Request(val method: String, val params: Params)
|
||||
abstract class Params
|
||||
abstract class Request(var method: String? = null)
|
||||
|
||||
// PayInvoice Call
|
||||
class PayInvoiceParams(var invoice: String? = null)
|
||||
|
||||
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
|
||||
class PayInvoiceParams(val invoice: String) : Params()
|
||||
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
|
||||
|
||||
companion object {
|
||||
fun create(bolt11: String): PayInvoiceMethod {
|
||||
return PayInvoiceMethod(PayInvoiceParams(bolt11))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RequestDeserializer :
|
||||
JsonDeserializer<Request?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Request? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val method = jsonObject.get("method")?.asString
|
||||
|
||||
if (method == "pay_invoice") {
|
||||
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
@@ -14,10 +19,14 @@ class LnZapPaymentResponseEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
// Once one of an app user decrypts the payment, all users else can see it.
|
||||
@Transient
|
||||
private var response: Response? = null
|
||||
|
||||
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? {
|
||||
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
@@ -34,15 +43,21 @@ class LnZapPaymentResponseEvent(
|
||||
}
|
||||
}
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
|
||||
} else {
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
|
||||
if (response != null) response
|
||||
|
||||
return try {
|
||||
if (content.isNotEmpty()) {
|
||||
val decrypted = decrypt(privKey, pubKey)
|
||||
response = gson.fromJson(decrypted, Response::class.java)
|
||||
response
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -58,7 +73,7 @@ abstract class Response(
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
@@ -86,3 +101,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseDeserializer :
|
||||
JsonDeserializer<Response?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Response? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val resultType = jsonObject.get("result_type")?.asString
|
||||
|
||||
if (resultType == "pay_invoice") {
|
||||
val result = jsonObject.get("result")?.asJsonObject
|
||||
val error = jsonObject.get("error")?.asJsonObject
|
||||
if (result != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (error != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
} else {
|
||||
// tries to guess
|
||||
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,37 @@ class LnZapRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient
|
||||
private var privateZapEvent: Event? = null
|
||||
|
||||
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() }
|
||||
|
||||
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
if (privateZapEvent != null) return privateZapEvent
|
||||
|
||||
val anonTag = 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.hexToByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
privateZapEvent = decryptedEvent
|
||||
return privateZapEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
@@ -59,7 +84,7 @@ class LnZapRequestEvent(
|
||||
} 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())
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
|
||||
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
|
||||
@@ -92,7 +117,7 @@ class LnZapRequestEvent(
|
||||
} 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())
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
@@ -109,7 +134,7 @@ class LnZapRequestEvent(
|
||||
return sha256.digest(strbyte)
|
||||
}
|
||||
|
||||
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
@@ -128,7 +153,7 @@ class LnZapRequestEvent(
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
private 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")
|
||||
@@ -150,61 +175,5 @@ class LnZapRequestEvent(
|
||||
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",
|
||||
"content": "",
|
||||
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
|
||||
"created_at": 1674164539,
|
||||
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
|
||||
"kind": 9734,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
||||
],
|
||||
[
|
||||
"relays",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr-relay.wlvs.space",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://relay.nostr.bg",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://brb.io",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"ws://monad.jb55.com:8080",
|
||||
"wss://relay.snort.social"
|
||||
],
|
||||
[
|
||||
"poll_option", "n"
|
||||
]
|
||||
],
|
||||
"ots": <base64-encoded OTS file data> // TODO
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
@@ -21,7 +21,7 @@ class MuteListEvent(
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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 PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
|
||||
|
||||
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
@@ -31,6 +35,11 @@ class PrivateDmEvent(
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun with(pubkeyHex: String): Boolean {
|
||||
return pubkeyHex == pubKey ||
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
|
||||
}
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -13,14 +13,14 @@ object Nip19 {
|
||||
USER, NOTE, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
val relay: String? = null,
|
||||
val author: String? = null,
|
||||
val kind: Long? = null,
|
||||
val kind: Int? = null,
|
||||
val additionalChars: String = ""
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.EVENT, hex, relay, author, kind)
|
||||
}
|
||||
@@ -140,15 +140,15 @@ object Nip19 {
|
||||
|
||||
val kind = tlv.get(Tlv.Type.KIND.id)
|
||||
?.get(0)
|
||||
?.let { Tlv.toInt32(it) }?.toLong()
|
||||
?.let { Tlv.toInt32(it) }
|
||||
|
||||
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
|
||||
}
|
||||
|
||||
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 author = author?.hexToByteArray()
|
||||
val idHex = idHex.hexToByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
// adds to database
|
||||
LocalCache.consume(event, null)
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: PrivateDmEvent) {
|
||||
val note = LocalCache.notes[event.id] ?: return
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
|
||||
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
|
||||
(
|
||||
it.pubkeyHex in followingKeySet || acc.userProfile()
|
||||
.hasSentMessagesTo(it)
|
||||
) && !acc.isHidden(it)
|
||||
}.toSet()
|
||||
|
||||
if (note.author in messagingWith) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
}
|
||||
}
|
||||
|
||||
val zappedContent = noteZapped?.event?.content()?.split("\n")?.get(0)?.take(50)
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
|
||||
object NotificationUtils {
|
||||
|
||||
// Notification ID.
|
||||
private var notificationId = 0
|
||||
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
|
||||
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
|
||||
if (dmChannel != null) return dmChannel!!
|
||||
|
||||
dmChannel = NotificationChannel(
|
||||
applicationContext.getString(R.string.app_notification_dms_channel_id),
|
||||
applicationContext.getString(R.string.app_notification_dms_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = applicationContext.getString(R.string.app_notification_dms_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(dmChannel!!)
|
||||
|
||||
return dmChannel!!
|
||||
}
|
||||
|
||||
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
|
||||
if (zapChannel != null) return zapChannel!!
|
||||
|
||||
zapChannel = NotificationChannel(
|
||||
applicationContext.getString(R.string.app_notification_zaps_channel_id),
|
||||
applicationContext.getString(R.string.app_notification_zaps_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = applicationContext.getString(R.string.app_notification_zaps_channel_description)
|
||||
}
|
||||
|
||||
// Register the channel with the system
|
||||
val notificationManager: NotificationManager =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
notificationManager.createNotificationChannel(zapChannel!!)
|
||||
|
||||
return zapChannel!!
|
||||
}
|
||||
|
||||
fun NotificationManager.sendZapNotification(
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
if (pictureUrl != null) {
|
||||
val request = ImageRequest.Builder(applicationContext)
|
||||
.data(pictureUrl)
|
||||
.build()
|
||||
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val imageResult = imageLoader.executeBlocking(request)
|
||||
sendNotification(
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
uri = uri,
|
||||
channelId,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
} else {
|
||||
sendNotification(
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = null,
|
||||
uri = uri,
|
||||
channelId,
|
||||
applicationContext = applicationContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendNotification(
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
picture: BitmapDrawable?,
|
||||
uri: String,
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||
data = Uri.parse(uri)
|
||||
}
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
notificationId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
|
||||
// Build the notification
|
||||
val builderPublic = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
channelId
|
||||
)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
// Build the notification
|
||||
val builder = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
channelId
|
||||
)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setLargeIcon(picture?.bitmap)
|
||||
.setGroup(messageTitle)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
.setPublicVersion(builderPublic.build())
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notify(notificationId, builder.build())
|
||||
|
||||
notificationId++
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all notifications.
|
||||
*/
|
||||
fun NotificationManager.cancelNotifications() {
|
||||
cancelAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class RegisterAccounts(
|
||||
private val accounts: List<AccountInfo>
|
||||
) {
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null) {
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
try {
|
||||
val jsonObject = """{
|
||||
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
|
||||
}
|
||||
"""
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val body = jsonObject.toRequestBody(mediaType)
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url("https://push.amethyst.social/register")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
client.newCall(request).execute()
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("FirebaseMsgService", "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun go(notificationToken: String) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,28 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
|
||||
}
|
||||
}
|
||||
|
||||
class EOSEAccount(var users: Map<User, EOSERelayList> = emptyMap()) {
|
||||
fun addOrUpdate(user: User, relayUrl: String, time: Long) {
|
||||
val relayList = users[user]
|
||||
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
|
||||
fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
|
||||
val relayList = followList[listCode]
|
||||
if (relayList == null) {
|
||||
users = users + mapOf(user to EOSERelayList(mapOf(relayUrl to EOSETime(time))))
|
||||
val newList = EOSERelayList()
|
||||
newList.addOrUpdate(relayUrl, time)
|
||||
followList = followList + mapOf(listCode to newList)
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
|
||||
fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) {
|
||||
val followList = users[user]
|
||||
if (followList == null) {
|
||||
val newList = EOSEFollowList()
|
||||
newList.addOrUpdate(listCode, relayUrl, time)
|
||||
users = users + mapOf(user to newList)
|
||||
} else {
|
||||
followList.addOrUpdate(listCode, relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
@@ -38,15 +44,30 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val nip19 = Nip19.uriToRoute(intent?.data?.toString())
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
val uri = intent?.data?.toString()
|
||||
|
||||
val startingPage = if (uri.equals("nostr:Notifications", true)) {
|
||||
Route.Notification.route.replace("{scrollToTop}", "true")
|
||||
} else {
|
||||
val nip19 = Nip19.uriToRoute(uri)
|
||||
when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> {
|
||||
if (nip19.kind == PrivateDmEvent.kind) {
|
||||
"Room/${nip19.author}"
|
||||
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
|
||||
"Channel/${nip19.hex}"
|
||||
} else {
|
||||
"Event/${nip19.hex}"
|
||||
}
|
||||
}
|
||||
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
} ?: try {
|
||||
intent?.data?.toString()?.let {
|
||||
uri?.let {
|
||||
Nip47.parse(it)
|
||||
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
|
||||
Route.Home.base + "?nip47=" + encodedUri
|
||||
@@ -92,11 +113,15 @@ class MainActivity : FragmentActivity() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
muted.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData = try {
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc = url.openConnection() as HttpURLConnection
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
if (responseCode in 300..400) {
|
||||
val newUrl: String = huc.getHeaderField("Location")
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc = url.openConnection() as HttpURLConnection
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
if (responseCode in 200..300) {
|
||||
huc.inputStream.use { it.readBytes() }
|
||||
} else {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ object ImageSaver {
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
byteArray: ByteArray,
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
@@ -87,7 +87,7 @@ object ImageSaver {
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = byteArray.inputStream().source().buffer()
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
@@ -126,8 +126,14 @@ object ImageSaver {
|
||||
)
|
||||
}
|
||||
|
||||
val masterUri = if (contentType.startsWith("image")) {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
val uri =
|
||||
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||
contentResolver.insert(masterUri, contentValues)
|
||||
checkNotNull(uri) {
|
||||
"Can't insert the new content"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
@@ -28,42 +29,61 @@ object ImageUploader {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
val myServer = if (server == ServersAvailable.IMGUR) {
|
||||
ImgurServer()
|
||||
} else if (server == ServersAvailable.NOSTRIMG) {
|
||||
NostrImgServer()
|
||||
} else {
|
||||
ImgurServer()
|
||||
val myServer = when (server) {
|
||||
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
ImgurServer()
|
||||
}
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
else -> {
|
||||
ImgurServer()
|
||||
}
|
||||
}
|
||||
|
||||
uploadImage(imageInputStream, contentType, myServer, onSuccess, onError)
|
||||
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
contentType: String?,
|
||||
server: FileServer,
|
||||
onSuccess: (String, String?) -> Unit,
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
category,
|
||||
server.inputParameterName(contentType),
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
@@ -72,17 +92,14 @@ object ImageUploader {
|
||||
)
|
||||
.build()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
server.clientID()?.let {
|
||||
requestBuilder.header("Authorization", it)
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
@@ -90,7 +107,7 @@ object ImageUploader {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSucess(body.string())
|
||||
val url = server.parseUrlFromSuccess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
@@ -113,7 +130,8 @@ object ImageUploader {
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSucess(body: String): String?
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(): String? = null
|
||||
}
|
||||
@@ -121,12 +139,16 @@ abstract class FileServer {
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -136,22 +158,42 @@ class ImgurServer : FileServer() {
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php"
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val url = jacksonObjectMapper().readTree(body) // return url.toString()
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "fileToUpload"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>()
|
||||
var mediaType by mutableStateOf<String?>(null)
|
||||
|
||||
var selectedServer by mutableStateOf<ServersAvailable?>(null)
|
||||
var description by mutableStateOf("")
|
||||
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = defaultServer()
|
||||
|
||||
if (selectedServer == ServersAvailable.IMGUR) {
|
||||
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = galleryUri ?: return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Loading"
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.20f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.30f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
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.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight().imePadding()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 10.dp)
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
if (postViewModel.isImage() == true) {
|
||||
AsyncImage(
|
||||
model = postViewModel.galleryUri.toString(),
|
||||
contentDescription = postViewModel.galleryUri.toString(),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
)
|
||||
} else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedServer = fileServers[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isNIP94Server(postViewModel.selectedServer) ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.content_description)) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
value = postViewModel.description,
|
||||
onValueChange = { postViewModel.description = 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,8 +588,11 @@ enum class ServersAvailable {
|
||||
IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@@ -610,8 +613,12 @@ fun ImageVideoDescription(
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
@@ -739,8 +746,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
if (isNIP94Server(selectedServer) ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -147,7 +146,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) {
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
@@ -287,13 +286,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
@@ -329,7 +321,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendNip95(bytes, headerInfo = it)
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery.
|
||||
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
||||
fun SaveToGallery(localFile: File, mimeType: String?) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
byteArray = byteArray,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
|
||||
@@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) {
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,23 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
@@ -21,56 +29,190 @@ fun ClickableRoute(
|
||||
navController: NavController
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
|
||||
DisplayUser(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
DisplayAddress(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
DisplayNote(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
DisplayEvent(nip19, navController)
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
@Composable
|
||||
private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Event/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
}
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
|
||||
} else {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var userBase by remember { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
CreateClickableText(
|
||||
user.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"User/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
|
||||
@@ -42,11 +42,13 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val whereToCut = remember(content) {
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
|
||||
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
}
|
||||
|
||||
val text = if (showFullText) {
|
||||
content
|
||||
|
||||
@@ -51,7 +51,7 @@ import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
@@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean {
|
||||
|
||||
val richTextDefaults = RichTextStyle().resolveDefaults()
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(
|
||||
content: String,
|
||||
@@ -85,58 +93,39 @@ fun RichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val isMarkdown = remember { isMarkdown(content) }
|
||||
|
||||
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 = markdownWithSpecialContent,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
if (isMarkdown) {
|
||||
RenderContentAsMarkdown(content, backgroundColor)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RichTextViewerState(
|
||||
val content: String,
|
||||
val urlSet: LinkedHashSet<String>,
|
||||
val imagesForPager: Map<String, ZoomableUrlContent>,
|
||||
val imageList: List<ZoomableUrlContent>
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
tags: List<List<String>>?,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
var processedState by remember {
|
||||
mutableStateOf<RichTextViewerState?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
@@ -151,67 +140,71 @@ fun RichTextViewer(
|
||||
}.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.trim().split(' ').reversed() else paragraph.trim().split(' ')
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val img = imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, imageList)
|
||||
} else if (urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
}
|
||||
}
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
processedState?.let { state ->
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) {
|
||||
paragraph.trim().split(' ')
|
||||
.reversed()
|
||||
} else {
|
||||
paragraph.trim().split(' ')
|
||||
}
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val img = state.imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, state.imageList)
|
||||
} else if (state.urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -219,69 +212,74 @@ fun RichTextViewer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", 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)
|
||||
)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (state.urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", 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)
|
||||
)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,21 +289,137 @@ 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)
|
||||
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
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 userState by userBase.live().metadata.observeAsState()
|
||||
val displayName = userState?.user?.bestDisplayName()
|
||||
if (displayName !== null) {
|
||||
return displayName
|
||||
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
|
||||
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
|
||||
var refresh by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
nip19References = returnNIP19References(content)
|
||||
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = refresh) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nip19References.forEach {
|
||||
var baseUser by remember { mutableStateOf<User?>(null) }
|
||||
var baseNote by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNote = note
|
||||
}
|
||||
}
|
||||
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
baseUser = user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
if (noteState?.note?.event != null) {
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
baseUser?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
if (userState?.user?.info != null) {
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdownWithSpecialContent?.let {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
return LocalCache.users[nip19.hex]?.bestDisplayName()
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnNIP19References(content: String): List<Nip19.Return> {
|
||||
val listOfReferences = mutableListOf<Nip19.Return>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
parsedNip19?.let {
|
||||
listOfReferences.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
@@ -319,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
} else if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val displayName = getDisplayNameFromUserNip19(parsedNip19)
|
||||
val displayName = getDisplayNameFromNip19(parsedNip19)
|
||||
if (displayName != null) {
|
||||
"[@$displayName](nostr://$word) "
|
||||
} else {
|
||||
@@ -359,9 +473,9 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNotePair = Pair(note, it.additionalChars)
|
||||
}
|
||||
} else {
|
||||
nip19Route = it
|
||||
}
|
||||
|
||||
nip19Route = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,8 +494,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
@@ -530,8 +643,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
@@ -92,7 +92,6 @@ object Robohash {
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.size(robotSize)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,18 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, 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) }
|
||||
@@ -106,7 +114,7 @@ fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onD
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray)
|
||||
Text(text = it, color = Color.Gray, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.platform.LocalContext
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
@@ -14,7 +15,7 @@ import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
@@ -28,11 +29,12 @@ fun UrlPreview(url: String, urlText: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.google.android.exoplayer2.C
|
||||
@@ -23,6 +55,16 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import java.io.File
|
||||
|
||||
public var muted = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
if (localFile != null) {
|
||||
VideoView(localFile.toUri(), description, onDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
@@ -48,35 +90,63 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
volume = 0f
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = muted.value) {
|
||||
exoPlayer.volume = if (muted.value) 0f else 1f
|
||||
}
|
||||
|
||||
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 {
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible) {
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
},
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton(muted, Modifier) {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -96,6 +166,75 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
var isVisible: Boolean? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(isVisible) {
|
||||
onVisibilityChanges(isVisible == true)
|
||||
}
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
isVisible = coordinates.isCompletelyVisible(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
if (!isAttached) return false
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = android.graphics.Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return false
|
||||
}
|
||||
val bounds = boundsInWindow()
|
||||
// Make sure we are completely in bounds.
|
||||
return bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
bounds.right <= globalRootRect.right &&
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.width(70.dp)
|
||||
.height(70.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (muted.value) {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeOff,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeUp,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -53,6 +55,7 @@ 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.isFinite
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -60,67 +63,80 @@ import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
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.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class ZoomableContent(
|
||||
val description: String? = null
|
||||
val description: String? = null,
|
||||
val dim: String? = null
|
||||
)
|
||||
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null,
|
||||
dim: String? = null,
|
||||
val uri: String? = null
|
||||
) : ZoomableContent(description)
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val bluehash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
abstract class ZoomablePreloadedContent(
|
||||
val localFile: File?,
|
||||
description: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val isVerified: Boolean? = null,
|
||||
dim: String? = null,
|
||||
val uri: String
|
||||
) : ZoomableContent(description)
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
class ZoomableBitmapImage(
|
||||
val byteArray: ByteArray?,
|
||||
val mimeType: String? = null,
|
||||
class ZoomableLocalImage(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val bluehash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
class ZoomableBytesVideo(
|
||||
val byteArray: ByteArray,
|
||||
val mimeType: String? = null,
|
||||
class ZoomableLocalVideo(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
@@ -140,49 +156,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
@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)
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
@@ -197,12 +176,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
|
||||
)
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
)
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
} else if (content is ZoomablePreloadedContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
@@ -213,63 +187,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
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 if (content is ZoomableUrlVideo) {
|
||||
VideoView(content.url, content.description) { dialogOpen = true }
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.byteArray,
|
||||
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 if (content is ZoomableBytesVideo) {
|
||||
when (content) {
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
|
||||
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
|
||||
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
@@ -277,8 +199,161 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
AsyncImage(
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error || content.localFile == null || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
width / height
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
cnt = content
|
||||
}
|
||||
}
|
||||
|
||||
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
if (content is ZoomableUrlContent) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
} else {
|
||||
@@ -317,39 +392,22 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlueHash(
|
||||
content: ZoomableUrlImage,
|
||||
private fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (content.bluehash == null) return
|
||||
if (blurhash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
content.bluehash
|
||||
blurhash
|
||||
),
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlueHash(
|
||||
content: ZoomableBitmapImage,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (content.bluehash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
content.bluehash
|
||||
),
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -384,8 +442,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
SaveToGallery(url = myContent.url)
|
||||
} else if (myContent is ZoomableBitmapImage && myContent.byteArray != null) {
|
||||
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,103 +464,28 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlContent) {
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
UrlImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.url, content.description)
|
||||
}
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.byteArray,
|
||||
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 if (content is ZoomableBytesVideo) {
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.byteArray, content.description)
|
||||
VideoView(content.localFile, content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||
private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
lateinit var channel: Channel
|
||||
var channelId: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) {
|
||||
account = accountLoggedIn
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
|
||||
this.account = accountLoggedIn
|
||||
this.channelId = channelId
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingChannel = channelId ?: return emptyList()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return channel.notes
|
||||
.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
@@ -24,6 +26,9 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingChannel = channelId ?: return emptySet()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return collection
|
||||
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
|
||||
@@ -3,21 +3,22 @@ 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
|
||||
|
||||
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var withUser: User? = null
|
||||
var withUser: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
withUser = userId
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingUser = withUser ?: return emptyList()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = withUser
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptyList()
|
||||
|
||||
@@ -32,8 +33,10 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingUser = withUser ?: return emptySet()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = withUser
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptySet()
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val privateChatrooms = me.privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
(it.pubkeyHex in followingKeySet || me.hasSentMessagesTo(it)) && !account.isHidden(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
|
||||
@@ -8,11 +8,12 @@ object ChatroomListNewFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = ChatroomListKnownFeedFilter.account.userProfile()
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet()
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
!me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
it.pubkeyHex !in followingKeySet && !me.hasSentMessagesTo(it) && account.isAcceptable(it)
|
||||
}
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull { it ->
|
||||
|
||||
@@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
@@ -23,14 +24,13 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -18,6 +19,10 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet()
|
||||
|
||||
val loggedInUser = account.userProfile()
|
||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||
|
||||
@@ -28,6 +33,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
it.event !is BadgeDefinitionEvent &&
|
||||
it.event !is BadgeProfilesEvent &&
|
||||
it.author !== loggedInUser &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
|
||||
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
|
||||
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
|
||||
tagsAnEventByUser(it, loggedInUser)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
object PeopleListFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
val lists = LocalCache.addressables.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is PeopleListEvent)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
return lists
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -9,9 +8,9 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -8,9 +8,9 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
|
||||
@@ -8,9 +8,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
|
||||
@@ -4,18 +4,21 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values.filter { it.author == user }
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent) }
|
||||
|
||||
return user?.notes
|
||||
?.plus(longFormNotes)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
@@ -8,8 +7,8 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Pair<Note, Note>> {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
return innerApplyFilter(collection)
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
|
||||
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter { it.createdAt()!! <= now }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
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,
|
||||
Route.Message,
|
||||
Route.Video,
|
||||
Route.Search,
|
||||
Route.Notification
|
||||
)
|
||||
@@ -158,15 +158,16 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
val notif = notifState.value ?: return
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||
@@ -34,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -58,10 +61,31 @@ fun AppNavigation(
|
||||
GlobalFeedFilter.account = account
|
||||
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
|
||||
VideoFeedFilter.account = account
|
||||
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
val notifFeedViewModel: NotificationViewModel = viewModel()
|
||||
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Route.Video.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
VideoScreen(
|
||||
videoFeedView = videoFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
scrollToTop = scrollToTop
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
if (scrollToTop) {
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
@@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,7 +44,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
@@ -55,6 +62,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
@@ -62,33 +70,68 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)) {
|
||||
when (currentRoute(navController)?.substringBefore("?")) {
|
||||
// Route.Profile.route -> TopBarWithBackButton(navController)
|
||||
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
|
||||
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultStoriesFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultNotificationFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) {
|
||||
AmethystIcon()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(coil.annotation.ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -115,48 +158,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map {
|
||||
"$it ${
|
||||
Client.getSubscriptionFilters(it)
|
||||
.joinToString { it.filter.toJson() }
|
||||
}"
|
||||
}.forEach {
|
||||
Log.d("STATE DUMP", it)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
content(account)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -186,23 +188,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
.height(34.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
LoggedInUserPictureDrawer(account) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -223,6 +212,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoggedInUserPictureDrawer(
|
||||
account: Account,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
.height(34.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
var followLists by remember { mutableStateOf(defaultOptions) }
|
||||
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
}
|
||||
}
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
|
||||
options = followNames.value,
|
||||
onSelect = {
|
||||
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
options: List<String>,
|
||||
explainers: List<String>? = null,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
var currentText by remember(placeholder) { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(placeholder)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
optionsShowing = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopBarWithBackButton(navController: NavHostController) {
|
||||
Column() {
|
||||
@@ -250,3 +340,51 @@ fun TopBarWithBackButton(navController: NavHostController) {
|
||||
Divider(thickness = 0.25.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AmethystIcon() {
|
||||
val context = LocalContext.current
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map {
|
||||
"$it ${
|
||||
Client.getSubscriptionFilters(it)
|
||||
.joinToString { it.filter.toJson() }
|
||||
}"
|
||||
}.forEach {
|
||||
Log.d("STATE DUMP", it)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ sealed class Route(
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
||||
)
|
||||
|
||||
object Video : Route(
|
||||
route = "Video?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_video,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
||||
)
|
||||
|
||||
object Notification : Route(
|
||||
route = "Notification?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_notifications,
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,11 +35,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -48,8 +48,8 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
@@ -57,7 +57,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
|
||||
@@ -71,20 +71,19 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -104,7 +103,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
Icon(
|
||||
imageVector = Icons.Default.MilitaryTech,
|
||||
null,
|
||||
modifier = Modifier.size(25.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
@@ -115,7 +116,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
Text(
|
||||
stringResource(R.string.new_badge_award_notif),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 5.dp).weight(1f)
|
||||
modifier = Modifier
|
||||
.padding(bottom = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
@@ -74,7 +74,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = loggedIn,
|
||||
size = 35.dp
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -28,10 +29,10 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -46,6 +47,8 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
@@ -66,20 +69,19 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -99,7 +101,9 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
@@ -109,7 +113,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
FlowRow() {
|
||||
boostSetCard.boostEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatroomCompose(
|
||||
@@ -64,6 +65,8 @@ fun ChatroomCompose(
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (note.channel() != null) {
|
||||
@@ -86,7 +89,7 @@ fun ChatroomCompose(
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
note.createdAt()?.let { timestamp ->
|
||||
hasNewMessages =
|
||||
timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
|
||||
@@ -131,7 +134,7 @@ fun ChatroomCompose(
|
||||
} else {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.verifiedRecipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
@@ -148,7 +151,7 @@ fun ChatroomCompose(
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
noteEvent?.let {
|
||||
hasNewMessages = it.createdAt() > notificationCache.cache.load(
|
||||
"Room/${userToComposeOn.pubkeyHex}"
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
@@ -94,6 +95,7 @@ fun ChatroomMessageCompose(
|
||||
var showHiddenNote by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
@@ -113,6 +115,8 @@ fun ChatroomMessageCompose(
|
||||
var alignment: Arrangement.Horizontal
|
||||
var shape: Shape
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
if (note.author == accountUser) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
alignment = Arrangement.End
|
||||
@@ -133,7 +137,7 @@ fun ChatroomMessageCompose(
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
@@ -333,11 +337,11 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
|
||||
Row() {
|
||||
LikeReaction(baseNote, accountViewModel)
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ZapReaction(baseNote, accountViewModel)
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ReplyReaction(baseNote, accountViewModel, showCounter = false) {
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -28,10 +29,10 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@@ -45,6 +46,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
@@ -66,20 +68,19 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -99,7 +100,9 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
@@ -109,7 +112,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
FlowRow() {
|
||||
likeSetCard.likeEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -26,13 +27,10 @@ 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
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -40,20 +38,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
val noteState by messageSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val newIsNew =
|
||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
|
||||
|
||||
if (newIsNew != isNew) {
|
||||
isNew = newIsNew
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +69,11 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
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
|
||||
}
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
@@ -120,7 +105,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
baseNote = messageSetCard.note,
|
||||
routeForLastRead = null,
|
||||
isBoostedNote = true,
|
||||
addMarginTop = false,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,15 +40,13 @@ 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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -56,19 +57,24 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, false)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
|
||||
|
||||
if (newIsNew != isNew) {
|
||||
isNew = newIsNew
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,31 +89,8 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
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
|
||||
}
|
||||
scope.launch {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
@@ -187,15 +170,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(65.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
@@ -225,7 +203,7 @@ fun AuthorGalleryZaps(
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
|
||||
AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +212,6 @@ fun AuthorGalleryZaps(
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -242,16 +219,19 @@ private fun AuthorPictureAndComment(
|
||||
val author = zapRequest.author ?: return
|
||||
|
||||
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
(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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,10 +254,14 @@ private fun AuthorPictureAndComment(
|
||||
Modifier
|
||||
}
|
||||
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FastNoteAuthorPicture(
|
||||
author = author,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
@@ -309,12 +293,16 @@ fun AuthorGallery(
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
authorNotes.take(50).forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (authorNotes.size > 50) {
|
||||
Text(" and ${authorNotes.size - 50} others")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +310,6 @@ fun AuthorGallery(
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
author: User,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
@@ -331,14 +318,12 @@ fun FastNoteAuthorPicture(
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = pictureModifier,
|
||||
onClick = {
|
||||
navController.navigate("User/${user.pubkeyHex}")
|
||||
}
|
||||
modifier = pictureModifier
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user