Compare commits

...

157 Commits

Author SHA1 Message Date
Vitor Pamplona
f02e18ff9b Fix background color from remember calls 2023-05-11 09:32:33 -04:00
Vitor Pamplona
b72b0b028f v0.46.0 2023-05-11 09:19:27 -04:00
Vitor Pamplona
eb9be2108d Adjustments to Tor/Orbot screens 2023-05-11 09:18:17 -04:00
Vitor Pamplona
875eb0df78 Merge pull request #357 from greenart7c3/main
Add option to setup connection through Orbot
2023-05-11 08:33:33 -04:00
Vitor Pamplona
ea8a665bc7 Merge pull request #401 from Radiokot/fix/actualize_ru_uk_23_05_10
Actualize Russian and Ukrainian translations
2023-05-11 08:33:11 -04:00
Vitor Pamplona
142f850eb7 Merge pull request #400 from Bardesss/patch-5
Update strings.xml (NL)
2023-05-11 08:33:04 -04:00
Vitor Pamplona
9f12fffd0d Merge pull request #399 from malimahda/main
values-fa/strings
2023-05-11 08:32:46 -04:00
Vitor Pamplona
28a1f0d6da v0.45.1 2023-05-10 19:36:07 -04:00
Vitor Pamplona
b1de36f423 Trying to improve rendering by allowing recompositions to perform layout faster. 2023-05-10 19:08:56 -04:00
Oleg Koretsky
ff54d581ae Actualize Russian and Ukrainian translations 2023-05-10 22:57:41 +03:00
Vitor Pamplona
8fcd05a6bb avoids cutting the first paragraph of notifcations 2023-05-10 14:03:05 -04:00
Vitor Pamplona
434c1e9af3 Don't try to decrypt if message is blank 2023-05-10 14:02:53 -04:00
Vitor Pamplona
a62f2e44d0 Only calls invalidate if things change 2023-05-10 14:02:38 -04:00
Vitor Pamplona
aa0ace5014 Delaying a bit more the average update filter for relays. 2023-05-10 14:02:05 -04:00
greenart7c3
ee4c54aa27 use httpclient.getHttpClient 2023-05-10 14:47:46 -03:00
greenart7c3
7ff408bfb9 Merge branch 'main' into main 2023-05-10 14:42:37 -03:00
Vitor Pamplona
c1d127ac1e Fixing blank messages and Zapped DM content 2023-05-10 13:31:25 -04:00
Vitor Pamplona
0b2e5de752 Uppercase 2023-05-10 13:30:52 -04:00
Vitor Pamplona
920ce54fe3 v0.45.0 2023-05-10 11:20:28 -04:00
Vitor Pamplona
c87b246a4e Fixing tests 2023-05-10 11:19:32 -04:00
Vitor Pamplona
b8bc370bc1 Support for Push Notifications in the PlayStore build 2023-05-10 11:16:47 -04:00
L
a8069f7a3e Update strings.xml 2023-05-10 16:17:01 +03:30
L
ca9b7adfa0 Update strings.xml
a few words
2023-05-10 15:21:40 +03:30
L
3c3b6544cf Update strings.xml
changed a couple of words
2023-05-10 15:02:28 +03:30
Bardesss
910849b3c7 Update strings.xml (NL)
Updated translation
2023-05-10 08:55:43 +00:00
L
411ba0f137 Merge pull request #1 from believethehype/persian_translation
fixed path to Persian translation file
2023-05-09 23:02:36 +03:30
Believethehype
d48a15bfb3 Update strings.xml 2023-05-09 21:15:55 +02:00
Believethehype
489941106b fixed path of persian translation 2023-05-09 21:14:50 +02:00
Believethehype
a0391fb8a5 Merge branch 'pr/1' into persian_translation 2023-05-09 21:11:40 +02:00
Vitor Pamplona
633be54dd4 Updates dependencies 2023-05-09 13:20:59 -04:00
Vitor Pamplona
470281fa87 Filters lists out of the user's profile 2023-05-09 12:58:56 -04:00
Vitor Pamplona
3287196243 updating gradle plugin 2023-05-09 11:10:53 -04:00
Vitor Pamplona
bb8bb16370 Better regex for nostr:n.. citations in text. 2023-05-09 11:10:39 -04:00
Vitor Pamplona
4325975686 v0.44.0 2023-05-08 16:20:40 -04:00
Vitor Pamplona
53ec9d777f - Moving postValue to IO thread
- launching navigate in a thread
2023-05-08 16:10:16 -04:00
Vitor Pamplona
3e6e7d4863 - Checks if the file still exists before discarding a duplicate event.
- Adding a per list EOSE
2023-05-08 15:34:59 -04:00
Vitor Pamplona
81290f2b26 Moving follows to the known list. 2023-05-08 14:58:39 -04:00
Vitor Pamplona
4dcf38c492 Fixing tab sliders from jumping from one state to the other 2023-05-08 14:46:23 -04:00
Vitor Pamplona
04c1300317 Adding List Choice to Notifications 2023-05-08 11:04:37 -04:00
greenart7c3
0d051117b2 add toast message to strings.xml 2023-05-08 11:03:17 -03:00
greenart7c3
d31eca1b42 Merge branch 'main' into main 2023-05-08 10:48:48 -03:00
greenart7c3
a503a1b218 show orbot dialog at login 2023-05-08 10:45:10 -03:00
Vitor Pamplona
70890def82 v0.43.2 2023-05-08 09:43:31 -04:00
Vitor Pamplona
d0eadcf30d Formatting 2023-05-08 09:39:54 -04:00
Vitor Pamplona
ae268cc197 Fixing NostrFiles dev downloader 2023-05-08 09:39:44 -04:00
L
0bf317a818 Update strings.xml 2023-05-08 12:01:21 +03:30
L
915f7c07e8 Update strings.xml 2023-05-08 10:40:59 +03:30
L
69a1302404 Update strings.xml 2023-05-08 09:47:05 +03:30
Vitor Pamplona
d6d273e34c v0.43.1 2023-05-07 21:46:01 -04:00
Vitor Pamplona
462c73341f Fixes cutting text in the wrong language 2023-05-07 21:45:38 -04:00
Vitor Pamplona
aa07fbfc94 Adjusting NostrFilesDev to the latest API 2023-05-07 18:47:39 -04:00
Vitor Pamplona
59d7f7cc70 Merge pull request #396 from believethehype/nostrfilesdev
Added Nostrfiles.dev Image hoster Backend Code
2023-05-07 18:42:39 -04:00
Vitor Pamplona
950eefa4e3 Merge branch 'main' into nostrfilesdev 2023-05-07 18:42:09 -04:00
Vitor Pamplona
f2bbdd44a6 Removing file transform function 2023-05-07 18:40:24 -04:00
Vitor Pamplona
cb09a26ef5 Refactoring the nostr.build uploader 2023-05-07 18:37:33 -04:00
L
43156e3e11 Update strings.xml 2023-05-07 23:51:47 +03:30
Vitor Pamplona
eba14cf9f1 v0.43.0 2023-05-07 16:14:18 -04:00
Vitor Pamplona
13de5973ca Improving performance of the RichText Viewer 2023-05-07 16:12:28 -04:00
Vitor Pamplona
936f750826 Moving private messages and channel loading to a thread. 2023-05-07 16:11:40 -04:00
L
faf215110a Update strings.xml 2023-05-07 23:34:27 +03:30
L
839e00ae36 Update strings.xml 2023-05-07 22:50:37 +03:30
L
b829a42d76 Update strings.xml 2023-05-07 22:45:58 +03:30
L
e1c3191f31 Update strings.xml 2023-05-07 22:18:25 +03:30
Vitor Pamplona
f44549de41 Loading Users and Notes in Launched Effects only because of @Synchronized in the getOrCreate methods in LocalCache 2023-05-07 12:51:12 -04:00
Vitor Pamplona
1c0fcc4ab8 Solves NPE when loading an invalid npub in a citation 2023-05-07 12:50:17 -04:00
Vitor Pamplona
fcc6793244 Limiting AuthorGalleries to 50 people 2023-05-07 08:53:52 -04:00
Vitor Pamplona
dea35552d5 Calculating ZapAmount and converting to the screen before exiting the IO thread 2023-05-06 22:27:00 -04:00
Vitor Pamplona
b093873eb0 Avoids trying to decrypt a zap that was not sent by the author. 2023-05-06 22:17:43 -04:00
Vitor Pamplona
e5de2c45de Moving withContext to scope.launch to avoid waiting the results. 2023-05-06 21:58:25 -04:00
Vitor Pamplona
70d8d9e2e0 formatting 2023-05-06 20:59:24 -04:00
Vitor Pamplona
dc0fe75b3d Removing crossfades that might reduce the loading speed 2023-05-06 20:59:09 -04:00
Vitor Pamplona
25a34dd2e8 formatting 2023-05-06 20:03:04 -04:00
Vitor Pamplona
90227e1cdc Refactoring NIP05 verification 2023-05-06 20:02:54 -04:00
Vitor Pamplona
bba45dd0a7 Moving clickable to Row and not AsyncImage because it's faster 2023-05-06 19:19:57 -04:00
Vitor Pamplona
fdca0af97b Improving performance of the UserNameDisplay by making variables immutable 2023-05-06 19:18:15 -04:00
Vitor Pamplona
795120e932 Improving performance of Badge events 2023-05-06 19:17:39 -04:00
Vitor Pamplona
79da9cee01 NoteCompose refactoring 2023-05-06 11:49:53 -04:00
Vitor Pamplona
fbbb9b0d72 Increasing sensitivity for Language identification options 2023-05-06 09:53:44 -04:00
Vitor Pamplona
97d56d61d1 Caching Private Zap decryption 2023-05-06 09:46:25 -04:00
Vitor Pamplona
f3316f53ea Renames Hex.toByteArray() to avoid confusing with String.toByteArray() 2023-05-06 09:24:39 -04:00
Vitor Pamplona
e3ccf71a84 v0.42.5 2023-05-05 18:50:19 -04:00
Vitor Pamplona
635279d59a Testing removing the gradient of quoted posts. 2023-05-05 18:49:32 -04:00
Vitor Pamplona
0c673e91c6 BugFix for displaying other people's list after loading them from their profile 2023-05-05 18:49:13 -04:00
Vitor Pamplona
3519e20a98 BugFix for the empty selection 2023-05-05 18:48:37 -04:00
Vitor Pamplona
7167d57d22 v0.42.4 2023-05-05 13:49:41 -04:00
Vitor Pamplona
86adac95c9 Adds protections for calculating zap amounts without a private key 2023-05-05 13:49:18 -04:00
Vitor Pamplona
cffcc158f5 0.42.3 2023-05-05 13:29:20 -04:00
Vitor Pamplona
49a8778975 BugFix for NPE on null ZappedNote 2023-05-05 13:26:55 -04:00
Believethehype
dd62d2bae4 activated nostrfiles_dev for stories feed. 2023-05-05 19:25:31 +02:00
Believethehype
3c3b6c97c0 deactivate nostrfiles_dev selection for now (because of space) 2023-05-05 19:21:25 +02:00
Believethehype
dabeea43ea added hoster nostrfiles.dev 2023-05-05 18:59:24 +02:00
Vitor Pamplona
5b6b6f19e5 Reverting background refactoring due to color being stuck in recompositions. 2023-05-05 10:01:37 -04:00
Vitor Pamplona
28370dbc01 Merge pull request #395 from ZsZolee/main
Follow list Hungarian translation
2023-05-05 09:11:03 -04:00
greenart7c3
28521c1a60 add configuration screen when enabling tor in the drawer 2023-05-05 08:25:48 -03:00
greenart7c3
9c30547bd7 fix build 2023-05-05 06:34:14 -03:00
greenart7c3
b07fd253c7 Merge branch 'main' into main 2023-05-05 06:11:24 -03:00
Zoltan
ab841c6490 Follow list Hungarian translation 2023-05-05 10:38:53 +02:00
Vitor Pamplona
12ff434a07 v0.42.2 2023-05-04 22:35:08 -04:00
Vitor Pamplona
51432c8c50 Fixing the toByteArray call 2023-05-04 22:34:09 -04:00
Vitor Pamplona
483e7d673c Refactoring part of NoteCompose 2023-05-04 21:57:19 -04:00
Vitor Pamplona
3ce6ce9951 v0.42.1 2023-05-04 21:14:55 -04:00
Vitor Pamplona
84f51dc473 Removing animation from blurhash 2023-05-04 21:14:42 -04:00
Vitor Pamplona
5f4886ce52 Fixes migration of follow lists from old accounts 2023-05-04 21:10:09 -04:00
Vitor Pamplona
dea6f0ca21 v0.42.0 2023-05-04 20:10:01 -04:00
Vitor Pamplona
01bdc41286 Merge remote-tracking branch 'origin/HEAD' 2023-05-04 20:08:50 -04:00
Vitor Pamplona
8c2e89197e Adds support for Private and Public Follow Lists 2023-05-04 20:08:36 -04:00
Vitor Pamplona
712950c2a1 Merge pull request #393 from believethehype/hashtag_fixes
Update HashtagIcon.kt
2023-05-04 11:49:38 -04:00
Believethehype
95cc2ee589 Update HashtagIcon.kt 2023-05-04 13:29:00 +02:00
Vitor Pamplona
76283463b0 v0.41.0 2023-05-03 18:26:52 -04:00
Vitor Pamplona
5179a821b2 Adjustments to the image upload choice to make sure they fit in a usual screen. 2023-05-03 18:25:01 -04:00
Vitor Pamplona
974a8011f9 Merge remote-tracking branch 'origin/main' 2023-05-03 17:49:14 -04:00
Vitor Pamplona
771bb3fc27 Merge pull request #391 from believethehype/nostrbuild
Added example for Image upload on nostr.build
2023-05-03 17:48:50 -04:00
Vitor Pamplona
10bd63ca2f Merge pull request #392 from Bardesss/patch-4
Update strings.xml (NL)
2023-05-03 17:44:29 -04:00
Vitor Pamplona
1fad3d1c8a Testing the removal of the image proxy server. 2023-05-03 17:20:10 -04:00
Believethehype
67cc24c4d9 added nostr.build NIP94 2023-05-03 18:13:18 +02:00
Vitor Pamplona
4368b34569 Fixes tall images bleeding into other posts. 2023-05-03 10:21:52 -04:00
greenart7c3
264d6a40b3 increased the default timeout when using proxy 2023-05-03 09:00:14 -03:00
Bardesss
65540c8a95 Update strings.xml (NL)
Updated translations
2023-05-03 08:13:35 +00:00
Bardesss
b801aa6bd1 Update strings.xml (NL)
Updated translation
2023-05-03 08:08:15 +00:00
Believethehype
7224ad4b05 image upload works to nostr.build 2023-05-03 00:48:01 +02:00
Believethehype
b043e2da2c Merge remote-tracking branch 'upstream/main' into nostrbuild 2023-05-03 00:27:39 +02:00
Vitor Pamplona
f8a60203ec Adds NFC support for NIP19 URIs 2023-05-02 17:59:12 -04:00
Vitor Pamplona
7f18bc3a88 0.40.6 2023-05-02 17:11:56 -04:00
Vitor Pamplona
31740d8855 Adding Post Not found on NIP95 images that are not found in any relay. 2023-05-02 17:06:32 -04:00
Vitor Pamplona
8641e0fec5 Only showing the loading url after 200 ms to avoid blinking the text. 2023-05-02 17:04:06 -04:00
Vitor Pamplona
c8f1e13448 Formatting 2023-05-02 16:47:02 -04:00
Vitor Pamplona
580cf76577 BugFix on calculating the Zap amount. 2023-05-02 16:46:52 -04:00
Vitor Pamplona
61549ebfd3 BugFix for video saving tool 2023-05-02 16:03:04 -04:00
Vitor Pamplona
797d1bb2b4 Don't try to draw local image if the file does not exist 2023-05-02 15:53:44 -04:00
Vitor Pamplona
99c16879c1 Uses resizeMode according to context. 2023-05-02 15:53:15 -04:00
Vitor Pamplona
0bf80e88ce Avoids videos getting out of the current page and leaking into the next page. 2023-05-02 15:45:32 -04:00
Vitor Pamplona
a71679ef43 Removing the exposure of the URL from the initial image and video loads. 2023-05-02 15:44:59 -04:00
Vitor Pamplona
4bbb34e8c9 v0.40.5 2023-05-02 14:38:18 -04:00
Vitor Pamplona
0e1420a626 Adjust volume based on changing global mute variable. 2023-05-02 14:38:09 -04:00
Vitor Pamplona
79782ee8e7 Fixes the volume adjusting interfering with other apps. 2023-05-02 14:32:49 -04:00
Vitor Pamplona
81b70780a6 v0.40.4 2023-05-02 12:42:38 -04:00
Vitor Pamplona
277fc5971e Moving Home feed to start first when loading the app. 2023-05-02 12:31:03 -04:00
Vitor Pamplona
850bc15f4f More visible buttons in the Video feed. 2023-05-02 12:20:05 -04:00
Vitor Pamplona
6d2db65c12 Basic Mp3 Support 2023-05-02 11:48:38 -04:00
Vitor Pamplona
89ce6dbab2 Forces search types to be used by the search components and reorganizes search 2023-05-02 11:39:29 -04:00
Vitor Pamplona
80bfbf68b4 Sending dimensions to Image files. 2023-05-02 10:02:45 -04:00
Vitor Pamplona
4464247ddb NVK is beautiful. 2023-05-02 08:20:45 -04:00
Believethehype
f889ff2547 added nostrbuild, can only upload very small files right now 2023-05-02 00:23:59 +02:00
greenart7c3
20a65a0878 check if orbot is installed 2023-04-28 11:21:06 -03:00
greenart7c3
6285bfaae7 updated string.xml 2023-04-28 08:15:04 -03:00
greenart7c3
77a1b2ba89 show a dialog when disabling tor 2023-04-26 14:41:36 -03:00
greenart7c3
3217d5095b created initProxy 2023-04-26 11:54:04 -03:00
greenart7c3
5d9d7ba16e created getProxy function 2023-04-26 10:19:30 -03:00
greenart7c3
c648f54b51 created HttpClient class 2023-04-26 10:03:51 -03:00
greenart7c3
4f8d33d850 fix build 2023-04-26 08:51:58 -03:00
greenart7c3
9f3d6a75e6 add proxy to all http clients 2023-04-26 08:42:49 -03:00
greenart7c3
ad420da6de added tor icon 2023-04-24 16:37:00 -03:00
greenart7c3
a354fe928b Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-24 08:16:55 -03:00
greenart7c3
cf8747b19f add a checkbox to use tor in the login screen, set the proxy in the account model construction 2023-04-20 15:30:04 -03:00
greenart7c3
2ae54b544f wip: add tor socks support 2023-04-20 08:21:11 -03:00
greenart7c3
bae1b6c6e6 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-03 13:55:06 -03:00
greenart7c3
ad2eabf386 add qrcode scanner to login page 2023-04-03 13:54:13 -03:00
147 changed files with 5720 additions and 2473 deletions

View File

@@ -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)

View File

@@ -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 137
versionName "0.40.3"
versionCode 154
versionName "0.46.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -85,8 +86,8 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -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
View 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"
}

View File

@@ -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())
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -0,0 +1,8 @@
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
}
}

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
</queries>
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
@@ -9,6 +11,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 +49,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 +72,7 @@
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
</application>
</manifest>

View File

@@ -6,8 +6,11 @@ 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.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
@@ -46,10 +49,15 @@ 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"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -197,15 +205,26 @@ 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)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount()).apply {
val acc = loadFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
}
fun loadFromEncryptedStorage(npub: String?): Account? {
encryptedPreferences(npub).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -217,6 +236,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, "[]"),
@@ -266,9 +288,12 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
@@ -278,10 +303,15 @@ object LocalPreferences {
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
latestContactList,
proxy,
proxyPort
)
return a

View File

@@ -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)

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -12,6 +13,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
@@ -25,7 +27,7 @@ object ServiceManager {
fun start() {
val myAccount = account
HttpClient.start(account)
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
@@ -33,10 +35,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 +64,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
Client.disconnect()
}

View File

@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
import java.math.BigDecimal
import java.net.Proxy
import java.util.Locale
val DefaultChannels = setOf(
@@ -38,6 +39,9 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
class Account(
val loggedIn: Persona,
@@ -50,10 +54,15 @@ 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,
var backupContactList: ContactListEvent? = null
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -173,44 +182,49 @@ class Account(
}
fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
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
return zapResponseEvent.response(
myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
myNip47.pubKeyHex.toByteArray()
)
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 calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
return zappedNote?.isZappedBy(userProfile(), this) == true
}
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
return zappedNote?.zappedAmount(
zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
zapPaymentRequest?.pubKeyHex?.toByteArray()
) ?: BigDecimal.ZERO
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, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
val privKey = nip47.secret?.hexToByteArray()
if (privKey != null) {
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
}
}
@@ -403,12 +417,12 @@ class Account(
}
}
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
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!!
)
@@ -417,6 +431,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
@@ -445,6 +460,7 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
@@ -727,6 +743,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()
@@ -739,6 +773,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
@@ -765,13 +830,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 {
@@ -792,7 +857,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
@@ -813,11 +878,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
}
@@ -864,7 +939,7 @@ class Account(
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
} ?: return null
// Ugly, but forces nostr.band as the only search-supporting relay today.
@@ -874,7 +949,8 @@ class Account(
Constants.forcedRelayForSearch.url,
Constants.forcedRelayForSearch.read,
Constants.forcedRelayForSearch.write,
Constants.forcedRelayForSearch.feedTypes
Constants.forcedRelayForSearch.feedTypes,
proxy
)
}
@@ -883,7 +959,7 @@ class Account(
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
}.toTypedArray()
}
@@ -965,14 +1041,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) {
@@ -999,6 +1068,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)) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -159,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"))
@@ -333,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)
@@ -343,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()}")
@@ -360,6 +374,8 @@ object LocalCache {
}
refreshObservers(note)
return note
}
fun consume(event: DeletionEvent) {
@@ -393,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)
@@ -722,20 +738,24 @@ object LocalCache {
note.addRelay(relay)
}
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
if (!file.exists()) {
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
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
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)
}
// 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)
@@ -893,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 ->

View File

@@ -286,7 +286,7 @@ open class Note(val idHex: String) {
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey)
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null
@@ -410,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))
}
}
@@ -420,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) {

View File

@@ -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)

View File

@@ -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)

View File

@@ -59,7 +59,6 @@ object BlurHashRequester {
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}

View File

@@ -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,15 +14,20 @@ 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) {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} else {
onError()
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
@@ -43,7 +48,7 @@ class FileHeader(
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)
@@ -59,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()

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
object HttpClient {
private var proxy: Proxy? = null
fun start(account: Account?) {
this.proxy = account?.proxy
}
fun getHttpClient(): OkHttpClient {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.build()
}
fun getProxy(): Proxy? {
return proxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
}
}

View File

@@ -9,12 +9,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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,50 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is BookmarkListEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
is ChannelMetadataEvent -> LocalCache.consume(event)
is ChannelMuteUserEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event, relay)
is FileStorageEvent -> LocalCache.consume(event, relay)
is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
is HighlightEvent -> LocalCache.consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is 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)
}
}
@@ -169,7 +103,7 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay

View File

@@ -45,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)
@@ -59,13 +59,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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
@@ -79,13 +79,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}.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() {

View File

@@ -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, HighlightEvent.kind),
search = mySearchString,
limit = 20
)

View File

@@ -152,22 +152,30 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
if (!eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
if (eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(aTag: Note) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
}
}
}

View File

@@ -64,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
}
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
}
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
if (usersToWatch.contains(user)) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
}

View File

@@ -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()
}
@@ -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
)

View File

@@ -1,5 +1,6 @@
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
@@ -7,17 +8,47 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
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 = listOf(createGlobalFilter()).ifEmpty { null }
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.net.URLEncoder
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
class LightningAddressResolver() {
val client = HttpClient.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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))

View File

@@ -38,6 +38,8 @@ 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] }
@@ -83,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
}
@@ -208,6 +210,7 @@ open class Event(
.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)
@@ -237,6 +240,7 @@ open class Event(
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)

View File

@@ -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) },

View File

@@ -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) },

View File

@@ -0,0 +1,92 @@
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? {
if (content.isBlank()) return null
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
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
)
}
}
}

View File

@@ -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 {
}
}

View File

@@ -19,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)
@@ -39,16 +43,21 @@ class LnZapPaymentResponseEvent(
}
}
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, 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 {

View File

@@ -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
}
*/

View File

@@ -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) {

View File

@@ -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())
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
private fun notify(event: PrivateDmEvent) {
val note = LocalCache.notes[event.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
(
it.pubkeyHex in followingKeySet || acc.userProfile()
.hasSentMessagesTo(it)
) && !acc.isHidden(it)
}.toSet()
if (note.author in messagingWith) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext)
}
}
}
}
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
}
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext)
}
}
}
private fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts(
private val accounts: List<AccountInfo>
) {
// creates proof that it controls all accounts
private fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null) {
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
} else {
null
}
} else {
null
}
}
}
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
val client = HttpClient.getHttpClient()
client.newCall(request).execute()
} catch (e: java.lang.Exception) {
Log.e("FirebaseMsgService", "Unable to register with push server", e)
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -91,7 +92,7 @@ object Client : RelayPool.Listener {
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
relay.requestAndWatch {

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClient
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -10,7 +11,7 @@ object Constants {
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
}.toTypedArray()
}

View File

@@ -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)
}
}
}

View File

@@ -11,6 +11,8 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.Date
enum class FeedType {
@@ -23,9 +25,16 @@ class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet()
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.followRedirects(true)
.followSslRedirects(true)
.build()

View File

@@ -20,7 +20,12 @@ import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -39,15 +44,30 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val nip19 = Nip19.uriToRoute(intent?.data?.toString())
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
val uri = intent?.data?.toString()
val startingPage = if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
} ?: try {
intent?.data?.toString()?.let {
uri?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
@@ -100,6 +120,8 @@ class MainActivity : FragmentActivity() {
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
override fun onPause() {

View File

@@ -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
}
}

View File

@@ -10,6 +10,7 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okio.BufferedSource
import okio.IOException
@@ -32,7 +33,7 @@ object ImageSaver {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?
) {
val client = OkHttpClient.Builder().build()
val client = HttpClient.getHttpClient()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -126,8 +127,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"
}

View File

@@ -2,9 +2,11 @@ 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
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
@@ -28,42 +30,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 client = HttpClient.getHttpClient()
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 +93,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 +108,7 @@ object ImageUploader {
try {
check(response.isSuccessful)
response.body.use { body ->
val url = server.parseUrlFromSucess(body.string())
val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
@@ -113,7 +131,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 +140,16 @@ abstract class FileServer {
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null
}
@@ -136,22 +159,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

View File

@@ -11,10 +11,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.net.URL
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -44,6 +42,10 @@ open class NewMediaModel : ViewModel() {
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
}
}
@@ -112,18 +114,12 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = "Server Processing"
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(15000)
}
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
uploadingDescription.value = "Downloading"
uploadingPercentage.value = 0.60f
try {
val imageData = URL(imageUrl).readBytes()
if (imageData != null) {
uploadingPercentage.value = 0.80f
uploadingDescription.value = "Hashing"
@@ -135,7 +131,7 @@ open class NewMediaModel : ViewModel() {
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
val note = account?.sendHeader(it)
account?.sendHeader(it)
uploadingPercentage.value = 1.00f
isUploadingImage = false
onceUploaded()
@@ -151,8 +147,8 @@ open class NewMediaModel : ViewModel() {
}
}
)
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
} else {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
uploadingPercentage.value = 0.00f
uploadingDescription.value = null

View File

@@ -109,6 +109,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
}
}
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
@@ -116,6 +123,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
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))
)
@@ -190,8 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
)
}
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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"
}
}

View File

@@ -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} "
)

View File

@@ -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

View File

@@ -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.startsWith("# ") ||
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
)

View File

@@ -92,7 +92,6 @@ object Robohash {
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.crossfade(100)
.build()
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -30,24 +31,31 @@ fun RobohashAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
with(LocalDensity.current) {
AsyncImage(
model = Robohash.imageRequest(
LocalContext.current,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
val context = LocalContext.current
val size = with(LocalDensity.current) {
robotSize.roundToPx()
}
val imageRequest = remember(size, robot) {
Robohash.imageRequest(
context,
robot,
Size(size, size)
)
}
AsyncImage(
model = imageRequest,
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
var imageErrors = setOf<String>()
@@ -120,7 +128,7 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val proxy = model.proxyUrl()
val proxy = remember(model) { model.proxyUrl() }
if (proxy == null) {
RobohashAsyncImage(
robot = robot,

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.media.AudioManager
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
@@ -41,6 +41,7 @@ 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
@@ -50,19 +51,19 @@ import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.Util
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) {
VideoView(localFile.toUri(), description, onDialog)
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog)
}
}
@Composable
@@ -75,14 +76,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val myAudioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val myAudioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -97,16 +90,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setAudioAttributes(myAudioAttributes, true)
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
volume = 0f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
@@ -120,11 +104,16 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect(
Box() {
BoxWithConstraints() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
@@ -142,7 +131,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
)
controllerAutoShow = false
hideController()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
@@ -155,15 +144,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
MuteButton(muted, Modifier) {
muted.value = !muted.value
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
}
}

View File

@@ -3,9 +3,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -13,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
@@ -56,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
@@ -63,14 +63,17 @@ 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
@@ -78,53 +81,62 @@ 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 localFile: File,
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 localFile: File,
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()
@@ -144,45 +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.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
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,67 +187,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
}
}
if (content is ZoomableUrlImage) {
Box(contentAlignment = Alignment.Center) {
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))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
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.localFile,
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) {
VideoView(content.localFile, content.description) { dialogOpen = true }
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) {
@@ -281,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 {
@@ -321,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
)
}
@@ -388,7 +442,7 @@ 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.localFile != null) {
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
@@ -411,100 +465,19 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
@Composable
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.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
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) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
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.localFile,
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.localFile, content.description)
}
@@ -512,7 +485,7 @@ fun RenderImageOrVideo(content: ZoomableContent) {
}
@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 ->

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 ->

View File

@@ -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 ->

View File

@@ -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()

View File

@@ -24,9 +24,8 @@ 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()

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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> {

View File

@@ -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)

View File

@@ -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> {

View File

@@ -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>> {

View File

@@ -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.service.model.*
@@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter<Note>() {
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 { 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 {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.filter { it.createdAt()!! <= now }
.toSet()
}

View File

@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
@@ -159,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)
}
}

View File

@@ -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
)
}
}

View File

@@ -14,7 +14,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
@@ -24,7 +27,9 @@ import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -45,13 +50,17 @@ import androidx.navigation.NavController
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@@ -100,7 +109,12 @@ fun DrawerContent(
}
@Composable
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
fun ProfileContent(
baseAccountUser: User,
modifier: Modifier = Modifier,
scaffoldState: ScaffoldState,
navController: NavController
) {
val coroutineScope = rememberCoroutineScope()
val accountUserState by baseAccountUser.live().metadata.observeAsState()
@@ -197,11 +211,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
})
) {
Row() {
Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(
"${accountUserFollows.cachedFollowCount() ?: "--"}",
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(
"${accountUserFollows.cachedFollowerCount() ?: "--"}",
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.followers))
}
}
@@ -221,8 +241,12 @@ fun ListContent(
) {
val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(account.proxy != null) }
var disconnectTorDialog by remember { mutableStateOf(false) }
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
Column(modifier = modifier.fillMaxHeight()) {
Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState())) {
if (accountUser != null) {
NavigationRow(
title = stringResource(R.string.profile),
@@ -256,7 +280,30 @@ fun ListContent(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = { backupDialogOpen = true }
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
backupDialogOpen = true
}
)
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
IconRow(
title = textTorProxy,
icon = R.drawable.ic_tor,
tint = MaterialTheme.colors.onBackground,
onClick = {
if (checked) {
disconnectTorDialog = true
} else {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
conectOrbotDialogOpen = true
}
}
)
Spacer(modifier = Modifier.weight(1f))
@@ -272,6 +319,65 @@ fun ListContent(
if (backupDialogOpen) {
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
}
if (conectOrbotDialogOpen) {
ConnectOrbotDialog(
onClose = { conectOrbotDialogOpen = false },
onPost = {
conectOrbotDialogOpen = false
disconnectTorDialog = false
checked = true
enableTor(account, true, proxyPort)
},
proxyPort
)
}
if (disconnectTorDialog) {
AlertDialog(
title = {
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title))
},
text = {
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text))
},
onDismissRequest = {
disconnectTorDialog = false
},
confirmButton = {
TextButton(
onClick = {
disconnectTorDialog = false
checked = false
enableTor(account, false, proxyPort)
}
) {
Text(text = stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(
onClick = {
disconnectTorDialog = false
}
) {
Text(text = stringResource(R.string.no))
}
}
)
}
}
private fun enableTor(
account: Account,
checked: Boolean,
portNumber: MutableState<String>
) {
account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.pause()
ServiceManager.start()
}
@Composable

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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}"

View File

@@ -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)
}
}

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More