Compare commits

...

164 Commits

Author SHA1 Message Date
Vitor Pamplona
24d722cdb7 v0.36.0 2023-04-25 21:19:32 -04:00
Vitor Pamplona
14bc1fb7d4 Support for NIP-42 2023-04-25 21:18:33 -04:00
Vitor Pamplona
90955515d0 Updating readme with the new features. 2023-04-25 18:26:18 -04:00
Vitor Pamplona
a22ec35a26 v0.35.1 2023-04-25 18:16:51 -04:00
Vitor Pamplona
f38f5737b0 Opens image dialog in the page of the current selection. 2023-04-25 18:11:08 -04:00
Vitor Pamplona
e958515dad Improves configuration of Zap types (double click and click and hold) 2023-04-25 18:06:00 -04:00
Vitor Pamplona
add6b06446 Marks all Zaps to be private by default. 2023-04-25 17:13:07 -04:00
Vitor Pamplona
d070712f2b Activates gif preview in Notifications 2023-04-25 17:12:48 -04:00
Vitor Pamplona
b9f58b3701 Correctly updating the screen after zaps have been accounted for 2023-04-25 17:02:44 -04:00
Vitor Pamplona
91f3f036ae Making poll votes work with Private Zaps. 2023-04-25 16:35:42 -04:00
Vitor Pamplona
4ef90e26cf Merge pull request #377 from ZsZolee/main
New Hungarian translation
2023-04-25 14:27:13 -04:00
Vitor Pamplona
f7182d6393 Preview for quote noting. 2023-04-25 13:37:39 -04:00
Vitor Pamplona
99861facc9 Improving the performance and UI of Zap notification messages. 2023-04-25 09:21:12 -04:00
Vitor Pamplona
a5728daeb0 Adds FileHeader loading to a thread. 2023-04-25 08:20:51 -04:00
Zoltan
a4ac2eeb89 New Hungarian translation 2023-04-25 12:07:01 +02:00
Vitor Pamplona
a5a320b889 Merge pull request #376 from kiwiidb/main
fix bug in nip47 scheme check
2023-04-25 05:08:13 -04:00
kiwiidb
8f927c29e3 fix bug in nip47 scheme check 2023-04-25 10:15:07 +02:00
Vitor Pamplona
a739b27d14 v0.35.0 2023-04-24 21:04:01 -04:00
Vitor Pamplona
2f2ca97ec1 Updating dependencies. 2023-04-24 21:03:01 -04:00
Vitor Pamplona
114186ce06 Merge pull request #373 from vitorpamplona/new_wallet_connect
New wallet connect API
2023-04-24 18:55:40 -04:00
Vitor Pamplona
8187a7f5a9 removing debugging log 2023-04-24 18:54:19 -04:00
Vitor Pamplona
f8319c720a Support for the new JSON-based Wallet Connect API 2023-04-24 18:51:57 -04:00
Vitor Pamplona
3d93fe3d18 Merge remote-tracking branch 'origin/HEAD' into new_wallet_connect 2023-04-24 18:02:24 -04:00
Vitor Pamplona
b4373ae1ed Deprecating #[]s tags 2023-04-24 17:58:35 -04:00
Vitor Pamplona
a37effd42d Merge pull request #372 from believethehype/privatezaps
Update for Custom Zaps
2023-04-24 10:25:47 -04:00
Vitor Pamplona
655cd20a01 Adds support for video and image descriptions for accessibility use cases 2023-04-24 10:21:01 -04:00
Believethehype
a13dbf2bda decode private zaps and show in notifications + show zap messages 2023-04-23 22:48:53 +02:00
Vitor Pamplona
f1affc2dbb Formatting 2023-04-23 16:32:50 -04:00
Vitor Pamplona
cbc0f95498 Show previews of most notes, including NIP94 - images, on new posts. 2023-04-23 16:32:44 -04:00
Vitor Pamplona
b3f8b06fea formatting improvements 2023-04-23 16:10:20 -04:00
Vitor Pamplona
ed4c19ac79 Adjusts the Wallet Connect API to the new RPC design 2023-04-23 16:10:09 -04:00
Believethehype
37bd7e34a0 remember default ZapType
Based on last selection in ZapCustomDialog, Reaction Row Zaps will use the same zap type

Currently Zaps in Polls are always public.
2023-04-23 18:53:38 +02:00
Vitor Pamplona
c1d05f8b2f Adds some margin for reply quotes 2023-04-22 15:14:00 -04:00
Vitor Pamplona
9c715dd381 Fixing the position of the image loading animation 2023-04-22 15:02:33 -04:00
Vitor Pamplona
a720a636d3 Merge pull request #348 from believethehype/private_zaps
Send Private Zaps
2023-04-22 14:52:06 -04:00
Vitor Pamplona
5322d6e900 Merge pull request #367 from jltdhome/main
Added Adaptive Icon
2023-04-22 14:50:42 -04:00
Vitor Pamplona
9d849f12c0 Merge pull request #368 from believethehype/hashtags_release
New HashTag Icons #footstr #grownostr
2023-04-22 14:49:17 -04:00
Believethehype
18dc5354bd removed unused graphics 2023-04-22 20:13:23 +02:00
Believethehype
a013f1f1cc added new hashtag icons 2023-04-22 20:11:05 +02:00
Believethehype
e1d236d9cf Merge remote-tracking branch 'upstream/main' into hashtags_release 2023-04-22 20:04:04 +02:00
Josh
12a2a21c0e Added Adaptive Icon
Take advantage of themed icons in Android 13+
2023-04-21 21:06:19 -07:00
Believethehype
9ae13a9f97 Update LnZapRequestEvent.kt 2023-04-22 05:54:43 +02:00
Believethehype
bd64a7de63 clean up 2023-04-22 05:50:38 +02:00
Believethehype
12a3cd5113 Update LocalCache.kt 2023-04-22 05:37:52 +02:00
Believethehype
1d23e87bd9 refactoring 2023-04-22 05:32:48 +02:00
Believethehype
b23bea8971 Merge remote-tracking branch 'upstream/main' into private_zaps 2023-04-22 05:06:59 +02:00
Vitor Pamplona
830ade9ad6 v0.34.1 2023-04-21 18:22:11 -04:00
Vitor Pamplona
ed07248f7d Correctly calculates the total of Zaps in a poll 2023-04-21 18:20:26 -04:00
Vitor Pamplona
9aa5993748 Removes unnecessary logs 2023-04-21 18:20:15 -04:00
Vitor Pamplona
e7909beaab Remove spaces after bench and image previews 2023-04-21 18:20:00 -04:00
Vitor Pamplona
79e3450a46 v0.34.0 2023-04-21 17:07:07 -04:00
Vitor Pamplona
783204b57f Support for NIP-94 2023-04-21 17:01:42 -04:00
Vitor Pamplona
1adb2b2caa Fix crashing when changing users. 2023-04-21 11:00:06 -04:00
Vitor Pamplona
3f579793ca v0.33.2 2023-04-21 10:21:50 -04:00
Vitor Pamplona
8040608e14 Fixes: https://github.com/vitorpamplona/amethyst/issues/359 2023-04-21 10:21:24 -04:00
Believethehype
2d202e2244 resolving conflicts with latest main branch. 2023-04-21 15:48:04 +02:00
Vitor Pamplona
2e66d278ad Merge pull request #362 from vivganes/patch-361
Show clickable phone number and nostr:npub reference in markdown notes
2023-04-21 08:48:50 -04:00
Vitor Pamplona
1bfbf3643d Merge branch 'main' into patch-361 2023-04-21 08:48:41 -04:00
Vitor Pamplona
5990bf0213 Merge pull request #360 from Bardesss/patch-3
Update strings.xml (NL)
2023-04-21 08:44:33 -04:00
vivganes
5e3db99675 fix #361 2023-04-21 17:20:50 +05:30
Bardesss
a291741c37 Update strings.xml 2023-04-21 09:06:54 +00:00
Bardesss
94ee80d8ea Update strings.xml 2023-04-21 08:59:36 +00:00
Bardesss
f7de66c6a5 Update strings.xml (NL)
Added missing strings.
2023-04-21 07:34:42 +00:00
Vitor Pamplona
48d890b354 v0.33.1 2023-04-20 17:24:33 -04:00
Vitor Pamplona
c7a9bd1226 Using FlowRow to display relay icons. 2023-04-20 17:19:34 -04:00
Vitor Pamplona
c40c7bc62b Removing LazyGrid from reactions Row due to being slower than the Row itself. 2023-04-20 17:19:20 -04:00
Vitor Pamplona
cca7a10322 Caching images that cause errors and avoid calling them again. 2023-04-20 17:18:53 -04:00
Vitor Pamplona
fe6ef1df0a Migration to Gradle 8.0 2023-04-20 17:18:20 -04:00
Vitor Pamplona
3e27567532 Moves home and Notification bubbles to the adaptive feed. 2023-04-20 14:09:26 -04:00
Vitor Pamplona
0a15e079dc Compares room messages by reference. 2023-04-20 14:08:58 -04:00
Vitor Pamplona
48f4fc4a0f v0.33.0 2023-04-20 12:11:58 -04:00
Vitor Pamplona
d9396ce453 Migrating to the default Pager from Accompanist's library. 2023-04-20 10:55:00 -04:00
Vitor Pamplona
817c5172b6 Clearing some warnings up 2023-04-20 10:54:35 -04:00
Vitor Pamplona
cfdb20892e Updating dependencies 2023-04-20 10:53:09 -04:00
Vitor Pamplona
75922f93bc Moving ReactionsRow into a Lazy Grid 2023-04-20 10:09:28 -04:00
Vitor Pamplona
42801e3eca Using the URL Detector (which is faster) to catch urls in the text 2023-04-20 10:08:32 -04:00
Vitor Pamplona
64321dbe7c No need to sort. It will be resorted later. 2023-04-20 10:07:34 -04:00
Vitor Pamplona
b11b52b60c typo 2023-04-20 10:07:10 -04:00
Vitor Pamplona
00e5470642 Passing the size of the robohash rendering to the request in the hopes to improve performance. 2023-04-20 10:07:03 -04:00
Vitor Pamplona
7ee3b8a196 Improves performance of video loading and playback. 2023-04-20 10:06:12 -04:00
Vitor Pamplona
585bb57f64 Merge pull request #356 from vitorpamplona/additive_feed_test
Additive feeds (No more list reloadings in the main screens)
2023-04-19 16:49:37 -04:00
Vitor Pamplona
6981fe8f8a Adapting interfaces for the additive filter. 2023-04-19 16:35:26 -04:00
Vitor Pamplona
abdad7fbea solve recursive method call 2023-04-19 15:57:27 -04:00
Vitor Pamplona
57b35398be Activates updates 2023-04-19 15:57:17 -04:00
Vitor Pamplona
47398e6e86 Starts with an empty set as database 2023-04-19 15:56:58 -04:00
Vitor Pamplona
e775ae9ada Adding the new parameter. 2023-04-19 13:54:00 -04:00
Vitor Pamplona
6a9a321e2b Fixing format 2023-04-19 13:00:52 -04:00
Vitor Pamplona
0aee4407db Merge remote-tracking branch 'origin/HEAD' into additive_feed_test
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
2023-04-19 13:00:45 -04:00
Vitor Pamplona
a816fa561a Declares the use of the camera as a feature. 2023-04-19 11:34:38 -04:00
Vitor Pamplona
dc745c3cdb disables errors due to missing translations 2023-04-19 11:33:03 -04:00
Vitor Pamplona
0b30c83143 Moves coffee.xml to the right directory. 2023-04-19 11:32:52 -04:00
Vitor Pamplona
dc8804933d moving source code compatibility to java 17 2023-04-19 11:27:20 -04:00
Vitor Pamplona
b28f0bff74 Updating build scripts to java 17 2023-04-19 11:24:49 -04:00
Vitor Pamplona
e96e3c0760 Merge pull request #353 from ryogrid/quote_problem
fix bug: quote repost is tied up to a post which is replyed a little while ago
2023-04-19 10:38:40 -04:00
Vitor Pamplona
b7ea167420 Merge branch 'main' into quote_problem 2023-04-19 10:38:20 -04:00
Vitor Pamplona
744a55f497 Caching channel info JSON parse in NoteCompose. 2023-04-19 10:34:10 -04:00
Vitor Pamplona
b55f999efb Slight performance improvements on tag search. 2023-04-19 10:33:52 -04:00
Vitor Pamplona
4e09d9e54a Performance Improvements for Zaps in Polls. 2023-04-19 10:33:02 -04:00
Vitor Pamplona
5afa333fab Migration to Android Studio 2022.2.1 and Gradle 8 2023-04-19 09:58:11 -04:00
Ryo Kanbayashi
c0946f5c0e fix bug: quote repost is tied up to a post which is replyed a little while ago. (2) 2023-04-19 22:50:32 +09:00
Vitor Pamplona
3ece4fcf8b Merge pull request #352 from naderbal/drawer_back
Intercept back press when drawer is opened to close drawer
2023-04-18 17:10:15 -04:00
Vitor Pamplona
b1193c5d11 Merge pull request #344 from ZsZolee/main
Added new Hungarian translations
2023-04-18 17:09:43 -04:00
Vitor Pamplona
12b0afd9c6 Display note IDHex when measuring time. 2023-04-18 08:46:12 -04:00
Vitor Pamplona
df45d68b74 Caches user profile in Account. 2023-04-18 08:45:50 -04:00
Vitor Pamplona
11bf702e02 Moves DropDown state to the IO thread. 2023-04-18 08:45:34 -04:00
Vitor Pamplona
47e2683aa3 Moves firstIsTaggedHash search to the IO thread. 2023-04-18 08:45:02 -04:00
Vitor Pamplona
e4fc00669e Compares users by reference. 2023-04-18 08:44:32 -04:00
Vitor Pamplona
f64db1271b Uses createdAt instead of the card object, which can change randomly, to determine when to call LaunchedEffects. 2023-04-18 08:44:11 -04:00
Vitor Pamplona
35647a8361 Fixes DM link in Message Notifications 2023-04-18 08:42:58 -04:00
Vitor Pamplona
a2c8658972 Only calculates nip05 after identifying if it is correct. 2023-04-18 08:42:32 -04:00
Vitor Pamplona
38e18cf0a7 Simplifies comparison for Channels via Hex code instead of objects 2023-04-18 08:41:59 -04:00
Vitor Pamplona
78c9974036 Removes invalidateFilters Logging 2023-04-18 08:41:29 -04:00
Believethehype
7a60a6804a Update LnZapEventInterface.kt 2023-04-17 20:14:17 +02:00
Believethehype
5e6e2835ca Receiving and decoding private Zaps
Private zaps get decoded correctly.

TODO: Update events or at least notifications in Damus to replace anon sender with the one from decrypted message. Bonus: Show Content of the Zap Message
2023-04-17 20:10:36 +02:00
Ryo Kanbayashi
180083147d removed needless added newline. 2023-04-16 22:26:55 +09:00
Ryo Kanbayashi
50b0b5af37 fix bug: quote repost is tied up to a post which is replyed a little while ago. 2023-04-16 22:12:54 +09:00
nalbaltaji
9d27b8d47e Intercept back press when drawer is opened to close drawer 2023-04-15 18:59:44 +02:00
Believethehype
fbb4dc5e59 additional hashtags 2023-04-13 15:35:37 +02:00
Believethehype
a2296f2365 Update grownostr.xml 2023-04-13 14:38:20 +02:00
Believethehype
58ff2e6157 Update HashtagIcon.kt 2023-04-13 00:19:20 +02:00
Believethehype
ff490a2b14 Send Private Zaps 2023-04-13 00:15:36 +02:00
Believethehype
067d694036 some more community hashtags 2023-04-12 20:49:32 +02:00
Zoltan
401d807d29 Added new Hungarian translations 2023-04-11 10:26:19 +02:00
Vitor Pamplona
eab202f721 Merge pull request #340 from toadlyBroodle/main
#zappoll hashtag
2023-04-10 10:07:13 -04:00
Vitor Pamplona
f726c656b5 Merge pull request #343 from believethehype/zaptypes
introduce zap types, implemented non-zaps in custom zap dialog
2023-04-10 10:03:35 -04:00
Believethehype
37dab1c8b6 Update LnZapEvent.kt 2023-04-10 13:54:29 +02:00
Believethehype
9a5d8522fb added anonymous zaps 2023-04-10 13:25:09 +02:00
Believethehype
5abbd07722 introduce zap types, implemented non-zaps in custom zap dialog
Non-Zaps allow to tip the author of a note without using the zap mechanism (no record on the note)
2023-04-09 23:43:36 +02:00
toadlyBroodle
13579f9473 add #zappoll to new poll messages 2023-04-09 11:26:02 +09:00
Vitor Pamplona
cea9cdcd34 v0.32.3 2023-04-07 18:33:36 -04:00
Vitor Pamplona
ed22155940 Simplifying tag structure 2023-04-07 18:30:34 -04:00
Vitor Pamplona
43497f2ce0 fixes format 2023-04-07 18:26:19 -04:00
Vitor Pamplona
80e58f72f8 Fixes nprofile1 tags 2023-04-07 18:26:13 -04:00
Vitor Pamplona
234751a4b3 Avoids loading markdown style when it won't be used. 2023-04-07 18:11:50 -04:00
Vitor Pamplona
ef567110d0 Moving processing flags to Launched Effect. 2023-04-07 18:02:54 -04:00
Vitor Pamplona
42b89f438b Only calculates follows when needed. 2023-04-07 17:44:40 -04:00
Vitor Pamplona
b01df2b38e Fixes MultiSet Compose Routes 2023-04-07 17:41:00 -04:00
Vitor Pamplona
b89efb547e Correctly points Users to the right DM when clicking in the notification. 2023-04-07 17:38:25 -04:00
Vitor Pamplona
e6554a5b4a Moves tags calculation to Launched Effect 2023-04-07 17:38:02 -04:00
Vitor Pamplona
c58b13e2f7 Simplifies modifiers 2023-04-07 17:37:25 -04:00
Vitor Pamplona
241372ce61 Adds DMs to the id search filter 2023-04-07 17:37:07 -04:00
Vitor Pamplona
49ed594f53 Caches logged user in Note compose 2023-04-07 17:21:21 -04:00
Vitor Pamplona
03b76357ff Migrates poll and invoice option availability to a mutable state 2023-04-07 17:20:18 -04:00
Vitor Pamplona
97490bb6ef Using bitcoin icon instead of dollar. 2023-04-07 17:19:47 -04:00
Vitor Pamplona
dbf5267c5c Moves LnInvoice, Tags, BechLinks and Previews to LaunchedEffect 2023-04-07 16:58:25 -04:00
Vitor Pamplona
e1ce638d7f Compares by reference to simplify code 2023-04-07 16:56:20 -04:00
Vitor Pamplona
88b80a0da1 Typo 2023-04-07 16:56:08 -04:00
Vitor Pamplona
d6b571590a Sends simpler object to help compose's stability 2023-04-07 16:55:46 -04:00
Vitor Pamplona
83f3af340f Uses Kotlin's rounding function instead of java 2023-04-07 16:55:15 -04:00
Vitor Pamplona
e9cf9b21a8 Merge pull request #339 from toadlyBroodle/main
Prevent author vote manipulation
2023-04-07 14:43:00 -04:00
Vitor Pamplona
b1645590fe Merge pull request #336 from wklkejw239/patch-1
add contributors in README.md
2023-04-07 14:41:48 -04:00
Vitor Pamplona
a5da4ea53d Streamlines Global filter for performance 2023-04-07 14:26:23 -04:00
Vitor Pamplona
3a082d131f Streamlines Notification filter for performance 2023-04-07 14:26:07 -04:00
Vitor Pamplona
28d501ed02 Uses more direct functions to get and compare tags as opposed to the fail safe options. 2023-04-07 14:25:38 -04:00
Vitor Pamplona
a04fd4a9f2 Matches parameter names to the Event class. 2023-04-07 14:25:03 -04:00
Vitor Pamplona
2424dc3961 Brings the findCitedUsers function to BaseTextNote Event with cache. 2023-04-07 14:24:43 -04:00
Vitor Pamplona
58f1515387 Displays feed performance measurement before clipping to 1000 objects 2023-04-07 14:24:00 -04:00
Vitor Pamplona
9ee3da3910 Uses the inHidden in Hex 2023-04-07 14:23:34 -04:00
Vitor Pamplona
cc44b4dd55 Removes reposts from conversations 2023-04-07 14:23:19 -04:00
Vitor Pamplona
b8e4ccfc28 Lint mentions cannot be null. 2023-04-07 14:22:48 -04:00
Vitor Pamplona
0fc6757d4a Adds inHidden check for hex keys 2023-04-07 14:22:16 -04:00
toadlyBroodle
ee94cc6bf6 don't allow poll authors to vote in their own polls 2023-04-07 08:41:34 +09:00
vincivermeer
2bda50ad4a add contributors in README.md
Hello everyone!

I added the contributors in the readme.md file in the repository: vitorpamplona/amethyst. I would like to contribute to this pull-request and I hope you all accept it.

what do you all think of this idea?
2023-04-06 00:11:49 -03:00
Vitor Pamplona
9a6f88b81b Turning LocalCache Listeners into an Additive Feed type. 2023-03-29 15:14:52 -04:00
136 changed files with 4511 additions and 1665 deletions

View File

@@ -14,10 +14,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1

View File

@@ -12,10 +12,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up JDK 11
- name: Set up JDK 17
uses: actions/setup-java@v1
with:
java-version: 11
java-version: 17
- name: Cache gradle
uses: actions/cache@v1

View File

@@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
@@ -41,13 +41,23 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable uploads (NIP-94)
- [x] Login with QR
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
- [x] Bounty support (nostrbounties.com)
- [x] De-googled F-Droid flavor
- [x] External Identity Support (NIP-39)
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
@@ -59,7 +69,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] Polls (NIP-69)
# Development Overview
@@ -85,7 +95,7 @@ Lastly, the user's account information (priv key/pub key) is stored in the Andro
## Setup
Make sure to have the following pre-requisites installed:
1. Java 11
1. Java 17
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -167,6 +177,12 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
## Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
</a>
# MIT License
Copyright (c) 2023 Vitor Pamplona

View File

@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 117
versionName "0.32.2"
versionCode 126
versionName "0.36.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -57,12 +57,12 @@ android {
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '11'
jvmTarget = '17'
}
buildFeatures {
@@ -78,10 +78,14 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -104,13 +108,13 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "net.engawapg.lib:zoomable:1.4.0"
implementation 'net.engawapg.lib:zoomable:1.4.2'
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -123,7 +127,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
@@ -133,7 +137,7 @@ dependencies {
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.5'
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -142,15 +146,14 @@ dependencies {
// view svgs
implementation "io.coil-kt:coil-svg:$coil_version"
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
@@ -173,7 +176,7 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
testImplementation "io.mockk:mockk:1.13.4"
testImplementation 'io.mockk:mockk:1.13.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"

View File

@@ -0,0 +1,139 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
import fr.acinq.secp256k1.Hex
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PrivateZapTests {
@Test
fun testPollZap() {
val poll = Event.Companion.fromJson(
"""{
"content": "New poll \n\n #zappoll",
"created_at": 1682440713,
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
"kind": 6969,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
"tags": [
[
"poll_option",
"0",
"Test 1"
],
[
"poll_option",
"1",
"Test 2"
],
[
"value_maximum",
"null"
],
[
"value_minimum",
"null"
],
[
"consensus_threshold",
"null"
],
[
"closed_at",
"null"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
poll,
setOf("wss://relay.damus.io/"),
loggedIn,
0,
"",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
@Test
fun testKind1PrivateZap() {
val textNote = Event.Companion.fromJson(
"""{
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
"created_at": 1682369982,
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
"kind": 1,
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
"tags": [
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://relay.damus.io/"
]
}
""",
true
)
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
val privateZapRequest = LnZapRequestEvent.create(
textNote,
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
loggedIn,
null,
"test",
LnZapEvent.ZapType.PRIVATE
)
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
} else {
fail("Should not be null")
}
}
}

View File

@@ -2,8 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
@@ -16,7 +19,7 @@
android:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
@@ -48,6 +51,7 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrwalletconnect" />
<data android:scheme="nostr+walletconnect" />
</intent-filter>
<meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
@@ -42,6 +43,7 @@ private object PrefKeys {
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -191,6 +193,7 @@ object LocalPreferences {
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -217,6 +220,11 @@ object LocalPreferences {
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
@@ -260,6 +268,7 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
defaultZapType,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,

View File

@@ -18,7 +18,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory {
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -35,7 +35,9 @@ object VideoCache {
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}
fun get(): CacheDataSource.Factory {
return cacheDataSourceFactory
}
}

View File

@@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -42,6 +44,7 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -54,8 +57,14 @@ class Account(
val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
return userProfileCache ?: run {
val myUser: User = LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
userProfileCache = myUser
myUser
}
}
fun followingChannels(): List<Channel> {
@@ -137,7 +146,7 @@ class Account(
}
}
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = ""): LnZapRequestEvent? {
fun createZapRequestFor(note: Note, pollOption: Int?, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
if (!isWriteable()) return null
note.event?.let { event ->
@@ -147,7 +156,8 @@ class Account(
?: localRelays.map { it.url }.toSet(),
loggedIn.privKey!!,
pollOption,
message
message,
zapType
)
}
return null
@@ -157,13 +167,26 @@ class Account(
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(lnInvoice: String) {
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return
zapPaymentRequest?.let {
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
Client.send(event, it.relayUri)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start()
LocalCache.consume(event) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
if (privKey != null) {
onResponse(it.response(privKey, event.pubKey.toByteArray()))
}
}
Client.send(event, nip47.relayUri, wcListener.feedTypes) {
wcListener.destroy()
}
}
}
@@ -171,14 +194,15 @@ class Account(
return createZapRequestFor(user)
}
fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
fun createZapRequestFor(userPubKeyHex: String, message: String = "", zapType: LnZapEvent.ZapType): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(
userPubKeyHex,
userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(),
loggedIn.privKey!!,
message
message,
zapType
)
}
@@ -349,6 +373,25 @@ class Account(
}
}
fun sendHeader(headerInfo: FileHeader): Note? {
if (!isWriteable()) return null
val signedEvent = FileHeaderEvent.create(
url = headerInfo.url,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
if (!isWriteable()) return
@@ -526,6 +569,12 @@ class Account(
LocalCache.consume(event)
}
fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? {
if (!isWriteable()) return null
return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!)
}
fun removePublicBookmark(note: Note) {
if (!isWriteable()) return
@@ -597,6 +646,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
defaultZapType = zapType
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -642,11 +697,57 @@ class Account(
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
event?.content()
}
}
fun decryptZapContentAuthor(note: Note): Event? {
val event = note.event
val loggedInPrivateKey = loggedIn.privKey
return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) {
val recipientPK = event.zappedAuthor().firstOrNull()
val recipientPost = event.zappedPost().firstOrNull()
if (recipientPK == userProfile().pubkeyHex) {
// if the receiver is logged in, these are the params.
val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
val altPrivateKeyToUse = if (recipientPost != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPost,
event.createdAt
)
} else if (recipientPK != null) {
LnZapRequestEvent.createEncryptionPrivateKey(
loggedInPrivateKey.toHexKey(),
recipientPK,
event.createdAt
)
} else {
null
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
} else {
null
}
}
} else {
null
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
liveLanguages.invalidateData()
@@ -716,14 +817,15 @@ class Account(
}
}
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun isHidden(user: User) = isHidden(user.pubkeyHex)
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet() ?: emptySet()
return userProfile().cachedFollowingKeySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
return userProfile().cachedFollowingTagSet()
}
fun isAcceptable(user: User): Boolean {

View File

@@ -6,15 +6,16 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
}

View File

@@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(pubkeyRelay, restOfWord)
} else if (key.startsWith("nevent", true)) {
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(noteRelayId, restOfWord)
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
e.printStackTrace()

View File

@@ -4,9 +4,10 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
@@ -30,6 +31,8 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
return getOrCreateUser(key)
@@ -189,7 +192,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
@@ -218,7 +221,7 @@ object LocalCache {
author.addNote(note)
refreshObservers()
refreshObservers(note)
}
}
@@ -255,7 +258,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
@@ -268,7 +271,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
refreshObservers(note)
}
}
@@ -286,8 +289,6 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
refreshObservers()
}
}
@@ -309,7 +310,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -353,7 +354,7 @@ object LocalCache {
recipient.addMessage(author, note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: DeletionEvent) {
@@ -401,7 +402,7 @@ object LocalCache {
}
if (deletedAtLeastOne) {
live.invalidateData()
// refreshObservers()
}
}
@@ -427,7 +428,7 @@ object LocalCache {
it.addBoost(note)
}
refreshObservers()
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
@@ -465,6 +466,8 @@ object LocalCache {
it.addReport(note)
}
}
refreshObservers(note)
}
fun consume(event: ReportEvent, relay: Relay?) {
@@ -495,6 +498,8 @@ object LocalCache {
repliesTo.forEach {
it.addReport(note)
}
refreshObservers(note)
}
fun consume(event: ChannelCreateEvent) {
@@ -511,7 +516,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
}
@@ -531,7 +536,7 @@ object LocalCache {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers()
refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -578,7 +583,7 @@ object LocalCache {
it.addReply(note)
}
refreshObservers()
refreshObservers(note)
}
@Suppress("UNUSED_PARAMETER")
@@ -591,11 +596,10 @@ object LocalCache {
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
val zapRequest = event.zapRequest?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
@@ -620,6 +624,8 @@ object LocalCache {
mentions.forEach {
it.addZap(zapRequest, note)
}
refreshObservers(note)
}
fun consume(event: LnZapRequestEvent) {
@@ -643,6 +649,37 @@ object LocalCache {
mentions.forEach {
it.addZap(note, null)
}
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: LnZapPaymentRequestEvent) {
// Does nothing without a response callback.
}
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
awaitingPaymentRequests.put(event.id, onResponse)
}
fun consume(event: LnZapPaymentResponseEvent) {
val responseCallback = awaitingPaymentRequests[event.requestId()]
if (responseCallback != null) {
responseCallback(event)
}
}
fun findUsersStartingWith(username: String): List<User> {
@@ -694,11 +731,6 @@ object LocalCache {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
@@ -749,30 +781,23 @@ object LocalCache {
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val live: LocalCacheLiveData = LocalCacheLiveData()
private fun refreshObservers() {
live.invalidateData()
private fun refreshObservers(newNote: Note) {
live.invalidateData(newNote)
}
}
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) {
postValue(bundledNewNotes)
}
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}
class LocalCacheState(val cache: LocalCache)

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -20,6 +21,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
@@ -51,15 +53,21 @@ open class Note(val idHex: String) {
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? {
val channelHex =
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.let { it.id }
fun channelHex(): HexKey? {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
}
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address(): ATag? = null
@@ -192,17 +200,19 @@ open class Note(val idHex: String) {
}
}
fun isZappedBy(user: User): Boolean {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author == user }
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author == user }
return reactions.any { it.author === user }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author == user }
return boosts.any { it.author === user }
}
fun reportsBy(user: User): Set<Note> {
@@ -270,45 +280,6 @@ open class Note(val idHex: String) {
)
}
fun directlyCiteUsersHex(): Set<HexKey> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile ||
(userProfile in directlyCiteUsers()) ||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}

View File

@@ -47,6 +47,7 @@ class User(val pubkeyHex: String) {
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
override fun toString(): String = pubkeyHex
@@ -295,7 +296,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this == it.author }
return messagesToUser.roomMessages.any { this === it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {

View File

@@ -0,0 +1,203 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.Color
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.withSign
object BlurHashDecoder {
// cache Math.cos() calculations to improve performance.
// The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
// the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
private val cacheCosinesX = HashMap<Int, DoubleArray>()
private val cacheCosinesY = HashMap<Int, DoubleArray>()
/**
* Clear calculations stored in memory cache.
* The cache is not big, but will increase when many image sizes are used,
* if the app needs memory it is recommended to clear it.
*/
fun clearCache() {
cacheCosinesX.clear()
cacheCosinesY.clear()
}
/**
* Returns width/height
*/
fun aspectRatio(blurHash: String?): Float? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
return numCompX.toFloat() / numCompY.toFloat()
}
/**
* Decode a blur hash into a new bitmap.
*
* @param useCache use in memory cache for the calculated math, reused by images with same size.
* if the cache does not exist yet it will be created and populated with new calculations.
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
if (blurHash == null || blurHash.length < 6) {
return null
}
val numCompEnc = decode83(blurHash, 0, 1)
val numCompX = (numCompEnc % 9) + 1
val numCompY = (numCompEnc / 9) + 1
if (blurHash.length != 4 + 2 * numCompX * numCompY) {
return null
}
val maxAcEnc = decode83(blurHash, 1, 2)
val maxAc = (maxAcEnc + 1) / 166f
val colors = Array(numCompX * numCompY) { i ->
if (i == 0) {
val colorEnc = decode83(blurHash, 2, 6)
decodeDc(colorEnc)
} else {
val from = 4 + i * 2
val colorEnc = decode83(blurHash, from, from + 2)
decodeAc(colorEnc, maxAc * punch)
}
}
return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
}
private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
var result = 0
for (i in from until to) {
val index = charMap[str[i]] ?: -1
if (index != -1) {
result = result * 83 + index
}
}
return result
}
private fun decodeDc(colorEnc: Int): FloatArray {
val r = colorEnc shr 16
val g = (colorEnc shr 8) and 255
val b = colorEnc and 255
return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
}
private fun srgbToLinear(colorEnc: Int): Float {
val v = colorEnc / 255f
return if (v <= 0.04045f) {
(v / 12.92f)
} else {
((v + 0.055f) / 1.055f).pow(2.4f)
}
}
private fun decodeAc(value: Int, maxAc: Float): FloatArray {
val r = value / (19 * 19)
val g = (value / 19) % 19
val b = value % 19
return floatArrayOf(
signedPow2((r - 9) / 9.0f) * maxAc,
signedPow2((g - 9) / 9.0f) * maxAc,
signedPow2((b - 9) / 9.0f) * maxAc
)
}
private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
private fun composeBitmap(
width: Int,
height: Int,
numCompX: Int,
numCompY: Int,
colors: Array<FloatArray>,
useCache: Boolean
): Bitmap {
// use an array for better performance when writing pixel colors
val imageArray = IntArray(width * height)
val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
for (y in 0 until height) {
for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numCompY) {
for (i in 0 until numCompX) {
val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
val basis = (cosX * cosY).toFloat()
val color = colors[j * numCompX + i]
r += color[0] * basis
g += color[1] * basis
b += color[2] * basis
}
}
imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
}
}
return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
}
private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
calculate -> {
DoubleArray(height * numCompY).also {
cacheCosinesY[height * numCompY] = it
}
}
else -> {
cacheCosinesY[height * numCompY]!!
}
}
private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
calculate -> {
DoubleArray(width * numCompX).also {
cacheCosinesX[width * numCompX] = it
}
}
else -> cacheCosinesX[width * numCompX]!!
}
private fun DoubleArray.getCos(
calculate: Boolean,
x: Int,
numComp: Int,
y: Int,
size: Int
): Double {
if (calculate) {
this[x + numComp * y] = cos(Math.PI * y * x / size)
}
return this[x + numComp * y]
}
private fun linearToSrgb(value: Float): Int {
val v = value.coerceIn(0f, 1f)
return if (v <= 0.0031308f) {
(v * 12.92f * 255f + 0.5f).toInt()
} else {
((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
}
private val charMap = listOf(
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
'-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
)
.mapIndexed { i, c -> c to i }
.toMap()
}

View File

@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
class BlurHashFetcher(
private val options: Options,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): FetchResult {
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
val preferredWidth = 100
val bitmap = BlurHashDecoder.decode(
hash,
preferredWidth,
(preferredWidth * (1 / aspectRatio)).roundToInt()
)
if (bitmap == null) {
throw Exception("Unable to convert Bluehash $hash")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return BlurHashFetcher(options, data)
}
}
}
object BlurHashRequester {
fun imageRequest(context: Context, message: String): ImageRequest {
val encodedMessage = URLEncoder.encode(message, "utf-8")
return ImageRequest
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}

View File

@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest
import kotlin.math.roundToInt
class FileHeader(
val url: String,
val mimeType: String?,
val hash: String,
val size: Int,
val blurHash: String?,
val description: String? = null
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData = URL(fileUrl).readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(imageData).toHexKey()
val size = imageData.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
intArray,
0,
mBitmap.width,
0,
0,
mBitmap.width,
mBitmap.height
)
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
}
} else {
null
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
}
}
}
}

View File

@@ -14,7 +14,7 @@ object Nip47 {
val url = Uri.parse(uri)
if (url.scheme != "nostrwalletconnect") {
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
throw IllegalArgumentException("Not a Wallet Connect QR Code")
}

View File

@@ -13,9 +13,11 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrAccountDataSource : NostrDataSource("AccountData") {
@@ -25,7 +27,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -36,7 +38,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -47,7 +49,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -58,7 +60,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -69,7 +71,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -79,7 +81,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
}
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
@@ -88,6 +90,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
ChannelMessageEvent.kind,
BadgeAwardEvent.kind
),
@@ -112,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountBookmarkListFilter()
).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
if (this::account.isInitialized) {
val event = account.createAuthEvent(relay, challenge)
if (event != null) {
Client.send(
event,
relay.url
)
}
}
}
}

View File

@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -43,7 +44,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
)
fun createMyChannelsFilter() = TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList(),
@@ -54,7 +55,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
types = COMMON_FEED_TYPES, // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),

View File

@@ -35,6 +35,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
@@ -75,11 +76,14 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
@@ -122,12 +126,21 @@ abstract class NostrDataSource(val debugName: String) {
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge)
}
}
init {
Client.subscribe(clientListener)
}
fun destroy() {
stop()
Client.unsubscribe(clientListener)
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
@@ -154,7 +167,7 @@ abstract class NostrDataSource(val debugName: String) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
@@ -212,4 +225,5 @@ abstract class NostrDataSource(val debugName: String) {
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}

View File

@@ -4,7 +4,7 @@ import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -15,9 +15,16 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
tags = mapOf(
"t" to listOf(
hashToLoad,
hashToLoad.lowercase(),
hashToLoad.uppercase(),
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)

View File

@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrLnZapPaymentResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
private fun createWalletConnectServiceWatcher(): TypedFilter {
// downloads all the reactions to a given event.
return TypedFilter(
types = feedTypes,
filter = JsonFilter(
kinds = listOf(LnZapPaymentResponseEvent.kind),
authors = listOf(fromServiceHex),
tags = mapOf(
"e" to listOf(replyingToHex),
"p" to listOf(toUserHex)
),
limit = 1
)
)
}
val channel = requestNewChannel()
override fun updateChannelFilters() {
val wc = createWalletConnectServiceWatcher()
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
}

View File

@@ -2,7 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
@@ -34,7 +34,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
return listOfNotNull(
hexToWatch?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)
@@ -42,7 +42,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
},
hexToWatch?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
@@ -50,7 +50,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -58,7 +58,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
// downloads linked events to this event.
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()

View File

@@ -3,11 +3,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
@@ -20,12 +19,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
@@ -49,12 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
val now = Date().time / 1000
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
@@ -74,7 +69,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return reactionsToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
@@ -113,12 +108,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
// downloads linked events to this event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, FileHeaderEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -16,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -31,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
return usersToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -18,14 +18,14 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
.ifEmpty { null } ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()

View File

@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -22,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -33,7 +33,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -44,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createUserReceivedZapsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
@@ -54,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createFollowFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -65,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createFollowersFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
@@ -75,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createAcceptedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -86,7 +86,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createBookmarksFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
authors = listOf(it.pubkeyHex),
@@ -97,7 +97,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeAwardEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),

View File

@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
open class BaseTextNoteEvent(
id: HexKey,
@@ -15,6 +17,48 @@ open class BaseTextNoteEvent(
fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
private var citedUsersCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
val matcher = tagSearch.matcher(content)
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag.size > 1 && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
val matcher2 = nip19regex.matcher(content)
while (matcher2.find()) {
val uriScheme = matcher2.group(1) // nostr:
val type = matcher2.group(2) // npub1
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
}
citedUsersCache = returningList
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
@@ -22,15 +66,44 @@ open class BaseTextNoteEvent(
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
if (tag != null && tag.size > 1 && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
if (tag != null && tag.size > 1 && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
val matcher2 = nip19regex.matcher(content)
while (matcher2.find()) {
val uriScheme = matcher2.group(1) // nostr:
val type = matcher2.group(2) // npub1
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
}
return citations
}

View File

@@ -38,23 +38,23 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
ATag.parse(aTagValue, relay)
}
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun getPoWRank(): Int {
var rank = 0
@@ -79,7 +79,7 @@ open class Event(
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
} catch (e: Exception) {
null
}
@@ -222,8 +222,10 @@ open class Event(
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)

View File

@@ -24,11 +24,11 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun hashtags(): List<String>
fun getReward(): BigDecimal?

View File

@@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
companion object {
const val kind = 1063
private const val URL = "url"
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
private const val BLUR_HASH = "blurhash"
fun create(
url: String,
mimeType: String? = null,
description: String? = null,
hash: String? = null,
size: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
var tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
)
val content = description ?: ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
data class AESGCM(val key: String, val nonce: String)

View File

@@ -4,7 +4,6 @@ import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
@@ -14,25 +13,37 @@ class LnZapEvent(
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedPollOption(): Int? = containedPost()?.tags
?.filter { it.firstOrNull() == POLL_OPTION }
?.getOrNull(0)?.getOrNull(1)?.toInt()
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun zappedRequestAuthor(): String? = containedPost()?.pubKey()
override fun amount(): BigDecimal? {
return amount
override fun containedPost(): LnZapRequestEvent? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
} as? LnZapRequestEvent
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
init {
zapRequest = containedPost()
}
override fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun zappedPollOption(): Int? = try {
zapRequest?.tags?.firstOrNull { it.size > 1 && it[0] == POLL_OPTION }?.get(1)?.toInt()
} catch (e: Exception) {
Log.e("LnZapEvent", "ZappedPollOption failed to parse", e)
null
}
override fun zappedRequestAuthor(): String? = zapRequest?.pubKey()
override fun amount() = amount
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
@@ -42,31 +53,22 @@ class LnZapEvent(
null
}
}
override fun message(): String {
return message
}
val message = content
override fun containedPost(): Event? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
override fun content(): String {
return content
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 9735
}
enum class ZapType() {
PUBLIC,
PRIVATE,
ANONYMOUS,
NONZAP
}
}

View File

@@ -17,6 +17,4 @@ interface LnZapEventInterface : EventInterface {
fun amount(): BigDecimal?
fun containedPost(): Event?
fun message(): String
}

View File

@@ -16,6 +16,8 @@ class LnZapPaymentRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
@@ -37,9 +39,10 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
val content = Utils.encrypt(
lnInvoice,
serializedRequest,
privateKey,
walletServicePubkey.toByteArray()
)
@@ -53,3 +56,14 @@ class LnZapPaymentRequestEvent(
}
}
}
// REQUEST OBJECTS
abstract class Request(val method: String, val params: Params)
abstract class Params
// PayInvoice Call
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
class PayInvoiceParams(val invoice: String) : Params()
}

View File

@@ -0,0 +1,88 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
val retVal = Utils.decrypt(content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
null
}
}
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
} else {
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
companion object {
const val kind = 23195
}
}
// RESPONSE OBJECTS
abstract class Response(
@SerializedName("result_type")
val resultType: String
)
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
Response("pay_invoice") {
class PayInvoiceErrorParams(val code: ErrorType?, val message: String?)
enum class ErrorType {
@SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"])
RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds.
@SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"])
NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented.
@SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"])
INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount.
@SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"])
QUOTA_EXCEEDED, // The wallet has exceeded its spending quota.
@SerializedName(value = "restricted", alternate = ["RESTRICTED"])
RESTRICTED, // This public key is not allowed to do this operation.
@SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"])
UNAUTHORIZED, // This public key has no wallet connected.
@SerializedName(value = "internal", alternate = ["INTERNAL"])
INTERNAL, // An internal error.
@SerializedName(value = "other", alternate = ["OTHER"])
OTHER // Other error.
}
}

View File

@@ -1,9 +1,15 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.util.Date
import java.nio.charset.Charset
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class LnZapRequestEvent(
id: HexKey,
@@ -13,8 +19,12 @@ class LnZapRequestEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
companion object {
const val kind = 9734
@@ -25,10 +35,12 @@ class LnZapRequestEvent(
privateKey: ByteArray,
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
@@ -40,9 +52,21 @@ class LnZapRequestEvent(
if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString()))
}
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
tags = tags + listOf(listOf("anon", ""))
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = "" // make sure public content is empty, as the content is encrypted
privkey = encryptionPrivateKey // sign event with generated privkey
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
val sig = Utils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
@@ -51,22 +75,102 @@ class LnZapRequestEvent(
relays: Set<String>,
privateKey: ByteArray,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
tags = tags + listOf(listOf("anon", ""))
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = ""
privkey = encryptionPrivateKey
pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
val sig = Utils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return sha256.digest(strbyte)
}
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val keySpec = SecretKeySpec(sharedSecret, "AES")
val ivSpec = IvParameterSpec(iv)
var utf8message = msg.toByteArray(Charset.forName("utf-8"))
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
val encryptedMsg = cipher.doFinal(utf8message)
val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
return encryptedMsgBech32 + "_" + ivBech32
}
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size")
}
val parts = msg.split("_")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid message format")
}
val iv = parts[1].run { Bech32.decode(this).second }
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
try {
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
return String(decryptedMsgBytes)
} catch (ex: BadPaddingException) {
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",

View File

@@ -20,24 +20,17 @@ class PollNoteEvent(
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun pollOptions(): Map<Int, String> {
val map = mutableMapOf<Int, String>()
tags.filter { it.first() == POLL_OPTION }
.forEach { map[it[1].toInt()] = it[2] }
return map
}
fun pollOptions() =
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
.associate { it[1].toInt() to it[2] }
fun getTagInt(property: String): Int? {
val tagList = tags.filter {
it.firstOrNull() == property
}
val tag = tagList.getOrNull(0)
val s = tag?.getOrNull(1)
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
return if (s.isNullOrBlank() || s == "null") {
return if (number.isNullOrBlank() || number == "null") {
null
} else {
s.toInt()
number.toInt()
}
}

View File

@@ -21,7 +21,7 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -29,7 +29,7 @@ class PrivateDmEvent(
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {

View File

@@ -14,8 +14,8 @@ class ReactionEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun originalPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun originalAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
companion object {
const val kind = 7

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class RelayAuthEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
companion object {
const val kind = 22242
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
listOf("relay", relay),
listOf("challenge", challenge)
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -30,7 +30,7 @@ class ReportEvent(
}
fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "e" }
.map {
ReportedKey(
it[1],
@@ -39,7 +39,7 @@ class ReportEvent(
}
fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.filter { it.size > 1 && it[0] == "p" }
.map {
ReportedKey(
it[1],

View File

@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
import java.util.regex.Pattern
object Nip19 {
@@ -12,7 +15,14 @@ object Nip19 {
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
data class Return(
val type: Type,
val hex: String,
val relay: String? = null,
val author: String? = null,
val kind: Long? = null,
val additionalChars: String = ""
)
fun uriToRoute(uri: String?): Return? {
if (uri == null) return null
@@ -26,8 +36,23 @@ object Nip19 {
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) ?: "" // additional chars
val additionalChars = matcher.group(4) // additional chars
return parseComponents(uriScheme, type, key, additionalChars)
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
}
fun parseComponents(
uriScheme: String?,
type: String,
key: String?,
additionalChars: String?
): Return? {
return try {
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
@@ -38,12 +63,11 @@ object Nip19 {
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
parsed?.copy(additionalChars = additionalChars ?: "")
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
return null
}
private fun npub(bytes: ByteArray): Return {
@@ -79,7 +103,15 @@ object Nip19 {
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.EVENT, hex, relay)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }?.toLong()
return Return(Type.EVENT, hex, relay, author, kind)
}
private fun nrelay(bytes: ByteArray): Return? {
@@ -108,8 +140,31 @@ object Nip19 {
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
?.let { Tlv.toInt32(it) }?.toLong()
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
}
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray()
val author = author?.toByteArray()
val idHex = idHex.toByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
if (relay != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
}
if (author != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
}
if (kind != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
}
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
}
}

View File

@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.UUID
@@ -64,21 +65,49 @@ object Client : RelayPool.Listener {
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: EventInterface, relay: String? = null) {
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelay = relays.filter { it.url == relay }
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
if (useConnectedRelay.isNotEmpty()) {
useConnectedRelay.forEach {
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
it.send(signedEvent)
}
} else {
/** temporary connection */
Relay(relay, false, true, emptySet()).requestAndWatch() {
it.send(signedEvent)
it.disconnect()
newSporadicRelay(
relay,
feedTypes,
onConnected = { relay ->
relay.send(signedEvent)
},
onDone = onDone
)
}
}
}
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
RelayPool.addRelay(relay)
relay.requestAndWatch {
allSubscriptions().forEach {
relay.sendFilter(requestId = it)
}
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)
if (onDone != null) {
onDone()
}
}
}
@@ -131,6 +160,14 @@ object Client : RelayPool.Listener {
}
}
override fun onAuth(relay: Relay, challenge: String) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onAuth(relay, challenge) }
}
}
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
}
@@ -167,5 +204,7 @@ object Client : RelayPool.Listener {
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit
}
}

View File

@@ -13,9 +13,11 @@ import okhttp3.WebSocketListener
import java.util.Date
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
}
val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
class Relay(
var url: String,
var read: Boolean = true,
@@ -93,11 +95,15 @@ class Relay(
val type = msg[0].asString
val channel = msg[1].asString
// Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}")
when (type) {
"EVENT" -> {
val event = Event.fromJson(msg[2], Client.lenient)
// Log.w("Relay", "Relay onEVENT $url, $channel")
listeners.forEach {
it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient))
it.onEvent(this@Relay, channel, event)
if (afterEOSE) {
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
@@ -113,9 +119,13 @@ class Relay(
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
// Log.w("Relay", "Relay onOK $url, $channel")
// Log.w("Relay", "Relay on OK $url, $channel")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay AUTH $url, $channel")
it.onAuth(this@Relay, msg[1].asString)
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
@@ -266,6 +276,8 @@ class Relay(
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
fun onAuth(relay: Relay, challenge: String)
/**
* Connected to or disconnected from a relay
*

View File

@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onAuth(relay: Relay, challenge: String)
}
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
override fun onAuth(relay: Relay, challenge: String) {
listeners.forEach { it.onAuth(relay, challenge) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)

View File

@@ -19,6 +19,7 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -54,6 +55,9 @@ class MainActivity : FragmentActivity() {
null
}
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {

View File

@@ -15,7 +15,7 @@ object ImageUploader {
fun uploadImage(
uri: Uri,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
@@ -64,7 +64,7 @@ object ImageUploader {
"There must be an uploaded image URL in the response"
}
onSuccess(url)
onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()

View File

@@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
open fun addUserToMentions(user: User) {
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
open fun addNoteToReplyTos(note: Note) {
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
open fun tagIndex(user: User): Int {
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
open fun tagIndex(note: Note): Int {
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
@@ -56,19 +56,19 @@ class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var rep
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
"#[${tagIndex(user)}]${results.restOfWord}"
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
"#[${tagIndex(note)}]${results.restOfWord}"
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"#[${tagIndex(note)}]${results.restOfWord}"
"nostr:${note.idNote()}${results.restOfWord}"
} else {
word
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(

View File

@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel)
pollViewModel.pollOptions.values.forEachIndexed { index, element ->
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(pollViewModel, index)
}
Button(
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
) { index, item ->
) { _, item ->
UserLine(item, account) {
pollViewModel.autocompleteWithUser(item)
}

View File

@@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
@@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MonetizationOn
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -25,18 +34,22 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -44,12 +57,16 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
@@ -166,7 +183,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
)
if (postViewModel.wantsPoll) {
postViewModel.pollOptions.values.forEachIndexed { index, element ->
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
@@ -185,10 +202,23 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
ImageVideoDescription(
url,
onAdd = { description ->
postViewModel.upload(url, description, context)
},
onCancel = {
postViewModel.contentToAddUrl = null
}
)
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && user != null && postViewModel.wantsInvoice) {
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
@@ -233,6 +263,14 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (isBechLink(myUrlPreview)) {
BechLink(
myUrlPreview,
true,
MaterialTheme.colors.background,
accountViewModel,
navController
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
@@ -266,16 +304,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 10.dp)
) {
postViewModel.upload(it, context)
postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll()) {
if (postViewModel.canUsePoll) {
val hashtag = stringResource(R.string.poll_hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
}
}
if (postViewModel.canAddLnInvoice()) {
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
@@ -327,17 +367,17 @@ private fun AddLnInvoiceButton(
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.MonetizationOn,
imageVector = Icons.Default.CurrencyBitcoin,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.MonetizationOn,
imageVector = Icons.Default.CurrencyBitcoin,
null,
modifier = Modifier.size(20.dp),
tint = Color.Green.copy(alpha = 0.52f)
tint = BitcoinOrange
)
}
}
@@ -444,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
)
}
}
@Composable
fun ImageVideoDescription(
uri: Uri,
onAdd: (String) -> Unit,
onCancel: () -> Unit
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
val scope = rememberCoroutineScope()
val isImage = mediaType.startsWith("image")
val isVideo = mediaType.startsWith("video")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Text(
text = stringResource(
if (isImage) {
R.string.content_description_add_image
} else {
if (isVideo) {
R.string.content_description_add_video
} else {
R.string.content_description_add_document
}
}
),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.padding(start = 10.dp)
.weight(1.0f)
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = onCancel
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier
.padding(end = 5.dp)
.size(30.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
if (mediaType.startsWith("image")) {
AsyncImage(
model = uri.toString(),
contentDescription = uri.toString(),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
} else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(key1 = uri) {
scope.launch(Dispatchers.IO) {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
}
}
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "some useful description",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
)
}
} else {
VideoView(uri)
}
}
var message by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
)
)
Button(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
onAdd(message)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -37,7 +38,11 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
// Polls
var canUsePoll by mutableStateOf(false)
var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions()
@@ -53,6 +58,7 @@ open class NewPostViewModel : ViewModel() {
var isValidClosedAt = mutableStateOf(true)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
@@ -70,15 +76,20 @@ open class NewPostViewModel : ViewModel() {
this.mentions = currentMentions.plus(replyUser)
}
}
} ?: {
} ?: run {
replyTos = null
mentions = null
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
contentToAddUrl = null
this.account = account
}
@@ -99,20 +110,15 @@ open class NewPostViewModel : ViewModel() {
cancel()
}
fun upload(it: Uri, context: Context) {
fun upload(it: Uri, description: String, context: Context) {
isUploadingImage = true
contentToAddUrl = null
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
viewModelScope.launch(Dispatchers.IO) {
delay(2000)
urlPreview = findUrlInMessage()
}
onSuccess = { imageUrl, mimeType ->
createNIP97Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
@@ -125,6 +131,7 @@ open class NewPostViewModel : ViewModel() {
open fun cancel() {
message = TextFieldValue("")
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
@@ -188,14 +195,60 @@ open class NewPostViewModel : ViewModel() {
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() })
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
}
fun canUsePoll(): Boolean {
return originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
if (include) {
updateMessage(TextFieldValue(message.text + " $hashtag"))
} else {
updateMessage(
TextFieldValue(
message.text.replace(" $hashtag", "")
.replace(hashtag, "")
)
)
}
}
fun canAddLnInvoice(): Boolean {
return account?.userProfile()?.info?.lnAddress() != null
fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
if (mimeType?.startsWith("image/") == true) {
delay(2000)
} else {
delay(5000)
}
FileHeader.prepare(
imageUrl,
mimeType,
description,
onReady = {
val note = account?.sendHeader(it)
isUploadingImage = false
if (note == null) {
message = TextFieldValue(message.text + "\n\n" + imageUrl)
} else {
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
}
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
}
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
}
}

View File

@@ -171,7 +171,7 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},

View File

@@ -13,9 +13,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.actions.NewPollView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
@Composable
fun FabColumn(account: Account) {
@@ -88,7 +86,7 @@ fun FabColumn(account: Account) {
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
}
if (wantsToPoll) {

View File

@@ -16,18 +16,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewNoteButton(account: Account) {
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = account)
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
}
OutlinedButton(

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* This class is designed to have a waiting time between two calls of invalidate
@@ -44,3 +45,37 @@ class BundledUpdate(
}
}
}
/**
* This class is designed to have a waiting time between two calls of invalidate
*/
class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private var onlyOneInBlock = AtomicBoolean()
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
atomicSet.updateAndGet() {
it + newObject
}
if (onlyOneInBlock.getAndSet(true)) {
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate(atomicSet.getAndSet(emptySet()))
delay(delay)
onUpdate(atomicSet.getAndSet(emptySet()))
} finally {
withContext(NonCancellable) {
onlyOneInBlock.set(false)
}
}
}
}
}

View File

@@ -11,12 +11,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
baesNote: Note,
baseNote: Note,
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -5,10 +5,34 @@ import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MayBeWithdrawal(lnurlWord: String) {
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lnurlWord) {
withContext(Dispatchers.IO) {
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
}
}
lnWithdrawal?.let {
ClickableWithdrawal(withdrawalString = it)
}
?: Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun ClickableWithdrawal(withdrawalString: String) {

View File

@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
val amount = try {
LnInvoiceUtil.getAmountInSats(lnInvoice)
} catch (e: Exception) {
e.printStackTrace()
null
fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
} catch (e: Exception) {
e.printStackTrace()
null
}
lnInvoice = Pair(myInvoice, myInvoiceAmount)
}
}
}
lnInvoice?.let {
InvoicePreview(it.first, it.second)
}
?: Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
val context = LocalContext.current
Column(

View File

@@ -135,7 +135,7 @@ fun InvoiceRequest(
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
LightningAddressResolver().lnAddressInvoice(
lud16,

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@@ -14,7 +13,8 @@ import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -33,13 +33,18 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
@@ -80,109 +85,88 @@ fun RichTextViewer(
accountViewModel: AccountViewModel,
navController: NavController
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
Column(modifier = modifier.animateContentSize()) {
Column(modifier = modifier) {
if (content.startsWith("# ") ||
content.contains("##") ||
content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp
),
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.background(
MaterialTheme.colors.onSurface
.copy(alpha = 0.05f)
.compositeOver(backgroundColor)
)
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
)
)
)
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = content,
content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
} else {
val imagesForPager = mutableListOf<String>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
// sequence of images will render in a slideview
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
imagesForPager.add(word)
}
}
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableVideo(fullUrl)
} else {
null
}
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImageView(word, imagesForPager)
} else {
UrlPreview(word, "$word ")
}
val img = imagesForPager[word]
if (img != null) {
ZoomableContentView(img, imageList)
} else if (urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -206,7 +190,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -219,8 +203,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
@@ -228,7 +219,7 @@ fun RichTextViewer(
)
}
} else {
if (isValidURL(word)) {
if (urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
@@ -263,7 +254,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -276,8 +267,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
@@ -292,79 +290,149 @@ fun RichTextViewer(
}
}
@Composable
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
if (parsedNip19.type == Nip19.Type.USER) {
val userHex = parsedNip19.hex
val userBase = LocalCache.getOrCreateUser(userHex)
val userState by userBase.live().metadata.observeAsState()
val displayName = userState?.user?.bestDisplayName()
if (displayName !== null) {
return displayName
}
}
return null
}
@Composable
private fun returnMarkdownWithSpecialContent(content: String): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
returnContent += "[$word]($word) "
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromUserNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
} else {
"$word "
}
} else {
"$word "
}
} else {
returnContent += "$word "
}
}
returnContent += "\n"
}
return returnContent
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun isBechLink(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@").take(7)
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val nip19Route = Nip19.uriToRoute(word)
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
if (nip19Route == null) {
Text(text = "$word ")
} else {
if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateNote(nip19Route.hex)
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars)
}
} else {
ClickableRoute(nip19Route, navController)
nip19Route = it
}
} else {
ClickableRoute(nip19Route, navController)
}
} else {
ClickableRoute(nip19Route, navController)
}
}
if (canPreview) {
baseNotePair?.let {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
if (!it.second.isNullOrEmpty()) {
Text(
"${it.second} "
)
}
} ?: nip19Route?.let {
ClickableRoute(it, navController)
} ?: Text(text = "$word ")
} else {
nip19Route?.let {
ClickableRoute(it, navController)
} ?: Text(text = "$word ")
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
fun HashTag(word: String, navController: NavController) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
val (tag, suffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
tagSuffixPair = Pair(myTag, mySuffix)
}
}
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
tagSuffixPair?.let { tagPair ->
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
append("#${tagPair.first}")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
@@ -387,14 +455,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = hashtagIcon.modifier
)
}
)
)
@@ -405,69 +471,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
inlineContent = inlineContent
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
tagPair.second?.ifBlank { "" }?.let {
Text(text = "$it ")
}
} ?: Text(text = "$word ")
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val (index, extraCharacters) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (index == null) {
return Text(text = "$word ")
}
if (index >= 0 && index < tags.size) {
if (tags[index][0] == "p") {
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
if (baseUser != null) {
ClickableUserTag(baseUser, navController)
Text(text = "$extraCharacters ")
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
} else if (tags[index][0] == "e") {
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
if (note != null) {
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableNoteTag(note, navController)
Text(text = "$extraCharacters ")
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
}
}
}
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
} else {
Text(text = "$word ")
}
}
baseUserPair?.let {
ClickableUserTag(it.first, navController)
Text(text = "${it.second} ")
}
baseNotePair?.let {
if (canPreview) {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
}
} else {
ClickableNoteTag(it.first, navController)
Text(text = "${it.second} ")
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ")
}
}

View File

@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import okio.Buffer
import java.security.MessageDigest
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
class HashImageFetcher(
private val context: Context,
private val size: Size,
private val data: Uri
) : Fetcher {
@@ -79,16 +81,17 @@ class HashImageFetcher(
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
return HashImageFetcher(options.context, options.size, data)
}
}
}
object Robohash {
fun imageRequest(context: Context, message: String): ImageRequest {
fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
.size(robotSize)
.crossfade(100)
.build()
}

View File

@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
@Composable
fun RobohashAsyncImage(
robot: String,
robotSize: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
AsyncImage(
model = Robohash.imageRequest(LocalContext.current, robot),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
with(LocalDensity.current) {
AsyncImage(
model = Robohash.imageRequest(
LocalContext.current,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
),
contentDescription = contentDescription,
modifier = modifier,
transform = transform,
onState = onState,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
var imageErrors = setOf<String>()
@Composable
fun RobohashFallbackAsyncImage(
robot: String,
model: String?,
robotSize: Dp,
model: String,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val context = LocalContext.current
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
if (imageErrors.contains(model)) {
RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val context = LocalContext.current
val painter = with(LocalDensity.current) {
rememberAsyncImagePainter(
model = Robohash.imageRequest(
context,
robot,
Size(robotSize.roundToPx(), robotSize.roundToPx())
)
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
onError = {
imageErrors = imageErrors + model
}
)
}
}
@Composable
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (model.url == null) {
val proxy = model.proxyUrl()
if (proxy == null) {
RobohashAsyncImage(
robot = robot,
robotSize = model.size,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
} else {
RobohashFallbackAsyncImage(
robot = robot,
model = model.proxyUrl(),
robotSize = model.size,
model = proxy,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
@@ -39,7 +39,7 @@ fun SlidingCarousel(
Box(
modifier = modifier.fillMaxWidth()
) {
HorizontalPager(count = itemsCount, state = pagerState) { page ->
HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
itemContent(page)
}

View File

@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
@@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
@@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
@@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
@@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
Column() {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray)
}
}
}
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)

View File

@@ -41,24 +41,24 @@ fun UrlPreviewCard(
)
) {
Column {
val url = URL(previewInfo.url)
val validatedUrl = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(url, previewInfo.image).toString()
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
AsyncImage(
model = imageUrl,
contentDescription = stringResource(R.string.preview_card_image_for, url),
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
text = validatedUrl.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()

View File

@@ -1,17 +1,23 @@
package com.vitorpamplona.amethyst.ui.components
import android.net.Uri
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
@@ -19,46 +25,73 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
@Composable
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, onDialog)
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
description?.let {
mediaBuilder.setMediaMetadata(
MediaMetadata.Builder().setDisplayTitle(it).build()
)
}
val media = mediaBuilder.build()
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
prepare()
}
}
val playerView = remember {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
innerOnDialog(it)
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
else -> {}
}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
DisposableEffect(exoPlayer) {
onDispose {
exoPlayer.release()
lifecycle.removeObserver(observer)
}
}
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
playerView
}
)
}

View File

@@ -0,0 +1,417 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.security.MessageDigest
abstract class ZoomableContent(
val url: String,
val description: String? = null,
val hash: String? = null
)
class ZoomableImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null
) : ZoomableContent(url, description, hash)
class ZoomableVideo(
url: String,
description: String? = null,
hash: String? = null
) : ZoomableContent(url, description, hash)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableImage(fullUrl)
} else if (isVideo) {
ZoomableVideo(fullUrl)
} else {
ZoomableImage(fullUrl)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
val mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
)
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
if (imageState !is AsyncImagePainter.State.Success) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
} else {
VideoView(content.url, content.description) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
}
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
val pagerState: PagerState = remember { PagerState() }
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
val page = allImages.indexOf(imageUrl)
if (page > -1) {
pagerState.scrollToPage(page)
}
}
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url)
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
if (content is ZoomableImage) {
Box() {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
}
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
val imageFile = snapshot.data.toFile()
val bytes = imageFile.readBytes()
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(bytes).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}")
return hash == content.hash
}
return null
}
@Composable
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
if (verifiedHash == null) return
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
Box(
modifier
.width(40.dp)
.height(40.dp)
.padding(10.dp)
) {
Box(
Modifier
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
)
if (verifiedHash == true) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_passed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
modifier = Modifier.size(30.dp)
)
}
} else if (verifiedHash == false) {
IconButton(
onClick = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.hash_verification_failed),
Toast.LENGTH_LONG
).show()
}
}
) {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Hash",
modifier = Modifier.size(30.dp)
)
}
}
}
}

View File

@@ -1,189 +0,0 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
// store the dialog open or close state
var imageState by remember {
mutableStateOf<AsyncImagePainter.State?>(null)
}
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
ClickableUrl(urlText = "$word ", url = word)
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("")
appendInlineContent(myId, "[icon]")
}
}
val inlineContent = mapOf(
Pair(
myId,
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
LoadingAnimation()
}
)
)
// Empty Text for Size of Icon
Text(
text = emptytext,
inlineContent = inlineContent
)
}
} else {
VideoView(word) { dialogOpen = true }
}
if (dialogOpen) {
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column() {
var pagerState: PagerState = remember { PagerState() }
Row(
modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage])
}
if (allImages.size > 1) {
SlidingCarousel(
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
}
)
} else {
RenderImageOrVideo(imageUrl)
}
}
}
}
}
@Composable
private fun RenderImageOrVideo(imageUrl: String) {
val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = imageUrl,
contentDescription = stringResource(id = R.string.profile_image),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
)
} else {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(imageUrl)
}
}
}

View File

@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
object ChannelFeedFilter : FeedFilter<Note>() {
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
lateinit var channel: Channel
@@ -22,4 +22,14 @@ object ChannelFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() }
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter : FeedFilter<Note>() {
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
var account: Account? = null
var withUser: User? = null
@@ -30,4 +30,23 @@ object ChatroomFeedFilter : FeedFilter<Note>() {
.sortedBy { it.createdAt() }
.reversed()
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
val myAccount = account
val myUser = withUser
if (myAccount == null || myUser == null) return emptySet()
val messages = myAccount
.userProfile()
.privateChatrooms[myUser] ?: return emptySet()
return collection
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val me = account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it)
}

View File

@@ -4,16 +4,37 @@ import android.util.Log
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
abstract class FeedFilter<T>() {
abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
val (feed, elapsed) = measureTimedValue {
feed().take(1000)
feed()
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed.take(1000)
}
abstract fun feed(): List<T>
}
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
val newList = oldList.toSet() + newItemsToBeAdded
sort(newList).take(1000)
} else {
oldList
}
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
return feed
}
abstract fun feed(): List<T>
}

View File

@@ -3,57 +3,48 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.*
object GlobalFeedFilter : FeedFilter<Note>() {
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val followChannels = account.followingChannels
val followUsers = account.followingKeySet()
val now = System.currentTimeMillis() / 1000
val notes = LocalCache.notes.values
return collection
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
it.event is BaseTextNoteEvent && it.replyTo.isNullOrEmpty()
}
.filter {
val channel = it.channelHex()
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
(channel == null || channel !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
it.createdAt()!! <= now
}
.toList()
.toSet()
}
val longFormNotes = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= System.currentTimeMillis() / 1000
}
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -8,14 +8,27 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() {
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
return LocalCache.notes.values
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myTag = tag ?: return emptySet()
return collection
.asSequence()
.filter {
(
@@ -27,13 +40,10 @@ object HashtagFeedFilter : FeedFilter<Note>() {
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
.toSet()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -4,26 +4,37 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : FeedFilter<Note>() {
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
return collection
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is RepostEvent) &&
(it.event is TextNoteEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
.sortedBy { it.createdAt() }
.reversed()
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -8,34 +8,38 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter : FeedFilter<Note>() {
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
return collection
.asSequence()
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) &&
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
it.isNewThread()
}
.toSet()
}
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is LongTextNoteEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
}
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
}

View File

@@ -3,52 +3,56 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : FeedFilter<Note>() {
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val loggedInUser = account.userProfile()
return LocalCache.notes.values
.asSequence()
.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
}
.filter { it ->
it.event !is TextNoteEvent ||
(it.event as? TextNoteEvent)?.taggedEvents()?.any {
LocalCache.checkGetOrCreateNote(it)?.author == loggedInUser
} == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter { it ->
it.event !is PollNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is RepostEvent ||
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.sortedBy { it.createdAt() }
.toList()
.reversed()
return sort(innerApplyFilter(LocalCache.notes.values))
}
fun isDifferentAccount(account: Account): Boolean {
return this::account.isInitialized && this.account != account
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val loggedInUser = account.userProfile()
val loggedInUserHex = loggedInUser.pubkeyHex
return collection.filter {
it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
it.event !is LnZapRequestEvent &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.author !== loggedInUser &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser)
}.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
}
fun tagsAnEventByUser(note: Note, author: User): Boolean {
val event = note.event
if (event is BaseTextNoteEvent) {
return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
}
if (event is ReactionEvent) {
return note.replyTo?.lastOrNull()?.author === author
}
if (event is RepostEvent) {
return note.replyTo?.lastOrNull()?.author === author
}
return true
}
}

View File

@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
@@ -135,6 +136,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
@@ -159,13 +161,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache)
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
}
}
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache)
hasNewItems = route.hasNewItems(account, notif.cache, db)
}
}

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -11,8 +13,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
@OptIn(ExperimentalPagerApi::class)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppNavigation(
navController: NavHostController,

View File

@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
val arguments: List<NamedNavArgument> = emptyList()
) {
val base: String
@@ -30,7 +32,7 @@ sealed class Route(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
),
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Search : Route(
@@ -43,13 +45,13 @@ sealed class Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Message : Route(
route = "Message",
icon = R.drawable.ic_dm,
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object BlockedUsers : Route(
@@ -108,36 +110,72 @@ fun currentRoute(navController: NavHostController): String? {
return navBackStackEntry?.destination?.route
}
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
val lastTime = cache.load("HomeFollows")
open class LatestItem {
var newestItemPerAccount: Map<String, Note?> = mapOf()
HomeNewThreadFeedFilter.account = account
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
return (
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
?: 0
) > lastTime
if (newestItem == null) {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.feed().firstOrNull { it.createdAt() != null }
)
} else {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
)
}
return newestItemPerAccount[account.userProfile().pubkeyHex]
}
}
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
val lastTime = cache.load("Notification")
object HomeLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
NotificationFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
return (
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
?: 0
) > lastTime
return (newestItem?.createdAt() ?: 0) > lastTime
}
}
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
ChatroomListKnownFeedFilter.account = account
object NotificationLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
return (note.createdAt() ?: 0) > lastTime
return (newestItem?.createdAt() ?: 0) > lastTime
}
}
object MessagesLatestItem {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
ChatroomListKnownFeedFilter.account = account
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
return (note.createdAt() ?: 0) > lastTime
}
}

View File

@@ -246,30 +246,32 @@ fun ChatroomMessageCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
val event = note.event
if (event is ChannelCreateEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.created)} " + (
event.channelInfo().name
channelInfo.name
?: ""
) + " ${stringResource(R.string.with_description_of)} '" + (
event.channelInfo().about
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture)} '" + (
event.channelInfo().picture
channelInfo.picture
?: ""
) + "'"
)
} else if (event is ChannelMetadataEvent) {
val channelInfo = event.channelInfo()
Text(
text = note.author?.toBestDisplayName()
.toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + (
event.channelInfo().name
channelInfo.name
?: ""
) + "$', {stringResource(R.string.description_to)} '" + (
event.channelInfo().about
channelInfo.about
?: ""
) + "', ${stringResource(R.string.and_picture_to)} '" + (
event.channelInfo().picture
channelInfo.picture
?: ""
) + "'"
)
@@ -371,6 +373,7 @@ private fun RelayBadges(baseNote: Note) {
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),

View File

@@ -26,7 +26,9 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
@@ -46,7 +48,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = messageSetCard) {
LaunchedEffect(key1 = messageSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew =
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
@@ -64,14 +66,29 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
Column(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
if (noteEvent is ChannelMessageEvent) {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
}
},
onLongClick = { popupExpanded = true }

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -33,9 +34,13 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -59,7 +64,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = multiSetCard) {
LaunchedEffect(key1 = multiSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
@@ -78,14 +83,31 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
.background(backgroundColor)
.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
if (noteEvent is ChannelMessageEvent) {
note
.channel()
?.let {
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
note.channel()?.let {
navController.navigate("Channel/${it.idHex}")
}
}
},
onLongClick = { popupExpanded = true }
@@ -118,7 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
}
}
@@ -139,7 +161,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
AuthorGallery(multiSetCard.boostEvents, navController, account)
AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel)
}
}
@@ -160,7 +182,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
AuthorGallery(multiSetCard.likeEvents, navController, account)
AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel)
}
}
@@ -191,10 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
fun AuthorGalleryZaps(
authorNotes: Map<Note, Note>,
navController: NavController,
account: Account
account: Account,
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
@@ -202,12 +225,95 @@ fun AuthorGallery(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
}
}
}
}
@Composable
private fun AuthorPictureAndComment(
zapRequest: Note,
zapEvent: Note,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val author = zapRequest.author ?: return
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
LaunchedEffect(key1 = zapRequest.idHex) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
}
}
}
}
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
}
@Composable
private fun AuthorPictureAndComment(
author: User,
comment: String?,
navController: NavController,
accountUser: User,
accountViewModel: AccountViewModel
) {
val modifier = if (!comment.isNullOrBlank()) {
Modifier.fillMaxWidth()
} else {
Modifier
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
FastNoteAuthorPicture(
author = author,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
if (!comment.isNullOrBlank()) {
Spacer(modifier = Modifier.width(5.dp))
TranslatableRichTextViewer(
content = comment,
canPreview = true,
tags = null,
modifier = Modifier.weight(1f),
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account,
accountViewModel: AccountViewModel
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
val author = it.author
if (author != null) {
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
}
}
}
}
@@ -215,20 +321,16 @@ fun AuthorGallery(
@Composable
fun FastNoteAuthorPicture(
note: Note,
author: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
// can't be null if here
val author = note.author ?: return
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),

View File

@@ -96,7 +96,6 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_") {
@@ -108,6 +107,7 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
)
}
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,

View File

@@ -5,8 +5,6 @@ import android.graphics.Bitmap
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -61,11 +59,10 @@ import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import kotlin.math.ceil
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
@OptIn(ExperimentalTime::class)
@Composable
fun NoteCompose(
baseNote: Note,
@@ -96,7 +93,7 @@ fun NoteCompose(
)
}
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
Log.d("Time", "Note Compose in $elapsed for ${baseNote.idHex} ${baseNote.event?.kind()} ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
}
@OptIn(ExperimentalFoundationApi::class)
@@ -116,6 +113,7 @@ fun NoteComposeInner(
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val loggedIn = account.userProfile()
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
@@ -130,6 +128,19 @@ fun NoteComposeInner(
var moreActionsExpanded by remember { mutableStateOf(false) }
var isAcceptable by remember { mutableStateOf(true) }
var canPreview by remember { mutableStateOf(true) }
LaunchedEffect(key1 = noteReportsState) {
withContext(Dispatchers.IO) {
canPreview = note?.author === loggedIn ||
(note?.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
isAcceptable = account.isAcceptable(noteForReports)
}
}
val noteEvent = note?.event
val baseChannel = note?.channel()
@@ -141,11 +152,11 @@ fun NoteComposeInner(
),
isBoostedNote
)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
} else if (!isAcceptable && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
loggedIn,
modifier,
isBoostedNote,
navController,
@@ -156,6 +167,8 @@ fun NoteComposeInner(
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
@@ -193,7 +206,20 @@ fun NoteComposeInner(
navController.navigate("Channel/${it.idHex}")
}
} else if (noteEvent is PrivateDmEvent) {
navController.navigate("Room/${note.author?.pubkeyHex}")
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == accountViewModel.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
} else {
navController.navigate("Note/${note.idHex}")
}
@@ -218,7 +244,7 @@ fun NoteComposeInner(
.width(55.dp)
.padding(0.dp)
) {
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
NoteAuthorPicture(note, navController, loggedIn, 55.dp)
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
@@ -231,7 +257,7 @@ fun NoteComposeInner(
NoteAuthorPicture(
it,
navController,
account.userProfile(),
loggedIn,
35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
@@ -272,7 +298,7 @@ fun NoteComposeInner(
}
if (noteEvent is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
baseNote.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
} else {
@@ -286,7 +312,7 @@ fun NoteComposeInner(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isQuotedNote) {
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
NoteAuthorPicture(note, navController, loggedIn, 25.dp)
Spacer(Modifier.padding(horizontal = 5.dp))
NoteUsernameDisplay(note, Modifier.weight(1f))
} else {
@@ -310,7 +336,7 @@ fun NoteComposeInner(
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
modifier = Modifier.size(24.dp),
onClick = { moreActionsExpanded = true }
) {
Icon(
@@ -343,18 +369,13 @@ fun NoteComposeInner(
Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
baseNote = replyingDirectlyTo,
isQuotedNote = true,
modifier = Modifier
.padding(0.dp)
.padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -369,14 +390,14 @@ fun NoteComposeInner(
navController = navController
)
} else {
ReplyInformation(note.replyTo, sortedMentions, account, navController)
ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
}
Spacer(modifier = Modifier.height(5.dp))
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
.sortedBy { loggedIn.isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
@@ -427,9 +448,9 @@ fun NoteComposeInner(
thickness = 0.25.dp
)
} else if (noteEvent is LongTextNoteEvent) {
LongFormHeader(noteEvent, note, account.userProfile())
LongFormHeader(noteEvent, note, loggedIn)
ReactionsRow(note, accountViewModel)
ReactionsRow(note, accountViewModel, navController)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -445,7 +466,7 @@ fun NoteComposeInner(
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
userAccount = loggedIn,
size = 35.dp
)
}
@@ -464,23 +485,23 @@ fun NoteComposeInner(
)
}
ReactionsRow(note, accountViewModel)
ReactionsRow(note, accountViewModel, navController)
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
} else if (noteEvent is PrivateDmEvent &&
noteEvent.recipientPubKey() != account.userProfile().pubkeyHex &&
note.author != account.userProfile()
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
note.author !== loggedIn
) {
val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
"@${recepient?.pubkeyNpub()}"
"@${recipient?.pubkeyNpub()}"
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
@@ -491,7 +512,7 @@ fun NoteComposeInner(
)
if (!makeItShort) {
ReactionsRow(note, accountViewModel)
ReactionsRow(note, accountViewModel, navController)
}
Divider(
@@ -501,12 +522,8 @@ fun NoteComposeInner(
} else {
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
if (makeItShort && note.author == account.userProfile()) {
if (makeItShort && note.author == loggedIn) {
Text(
text = eventContent,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -524,7 +541,7 @@ fun NoteComposeInner(
navController
)
DisplayUncitedHashtags(noteEvent, eventContent, navController)
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
}
if (noteEvent is PollNoteEvent) {
@@ -539,7 +556,7 @@ fun NoteComposeInner(
}
if (!makeItShort) {
ReactionsRow(note, accountViewModel)
ReactionsRow(note, accountViewModel, navController)
}
Divider(
@@ -561,11 +578,17 @@ fun DisplayFollowingHashtagsInPost(
account: Account,
navController: NavController
) {
var firstTag by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteEvent) {
withContext(Dispatchers.IO) {
firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
}
}
Column() {
Row(verticalAlignment = Alignment.CenterVertically) {
val firstTag =
noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
firstTag?.let {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
@@ -582,11 +605,10 @@ fun DisplayFollowingHashtagsInPost(
@Composable
fun DisplayUncitedHashtags(
noteEvent: EventInterface,
hashtags: List<String>,
eventContent: String,
navController: NavController
) {
val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 5.dp)
@@ -756,6 +778,34 @@ fun BadgeDisplay(baseNote: Note) {
}
}
@Composable
fun FileHeaderDisplay(note: Note) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
var content by remember { mutableStateOf<ZoomableContent?>(null) }
LaunchedEffect(key1 = event.id) {
withContext(Dispatchers.IO) {
val blurHash = event.blurhash()
val hash = event.hash()
val description = event.content
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
} else {
ZoomableVideo(fullUrl, description, hash)
}
}
}
content?.let {
ZoomableContentView(content = it, listOf(it))
} ?: UrlPreview(fullUrl, "$fullUrl ")
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
@@ -848,63 +898,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
val noteRelays = noteRelaysState?.note ?: return
var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
withContext(Dispatchers.IO) {
val relayList = noteRelays.relays.map {
it.removePrefix("wss://").removePrefix("ws://")
}
val uri = LocalUriHandler.current
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
Spacer(Modifier.height(10.dp))
if (shouldListChange) {
lazyRelayList = relaysToDisplay
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
modifier = Modifier.height(height),
userScrollEnabled = false
) {
items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
Box(Modifier.padding(1.dp).size(15.dp)) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
// .border(1.dp, Color.Red)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
val nextShowMore = relayList.size > 3 && !expanded
if (nextShowMore != showShowMore) {
// only triggers recomposition when actually different
showShowMore = nextShowMore
}
}
}
if (noteRelays.size > 3 && !expanded) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
Spacer(Modifier.height(10.dp))
VerticalRelayPanelWithFlow(lazyRelayList)
if (showShowMore) {
ShowMoreRelaysButton {
expanded = true
}
}
}
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: List<String>
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
relays.forEach { url ->
RelayIconCompose(url)
}
}
}
@Composable
@Stable
private fun RelayIconCompose(url: String) {
val uri = LocalUriHandler.current
Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.width(13.dp)
.height(13.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://$url") })
)
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.height(25.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Top
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = onClick
) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
@@ -943,6 +1032,7 @@ fun NoteAuthorPicture(
if (author == null) {
RobohashAsyncImage(
robot = "authornotfound",
robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.width(size)
@@ -1062,6 +1152,13 @@ fun UserPicture(
}
}
data class DropDownParams(
val isFollowingAuthor: Boolean,
val isPrivateBookmarkNote: Boolean,
val isPublicBookmarkNote: Boolean,
val isLoggedUser: Boolean
)
@Composable
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val clipboardManager = LocalClipboardManager.current
@@ -1069,11 +1166,28 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
var state by remember {
mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false)
)
}
LaunchedEffect(key1 = note) {
withContext(Dispatchers.IO) {
state = DropDownParams(
accountViewModel.isFollowing(note.author),
accountViewModel.isInPrivateBookmarks(note),
accountViewModel.isInPublicBookmarks(note),
accountViewModel.isLoggedUser(note.author)
)
}
}
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (!accountViewModel.isFollowing(note.author)) {
if (!state.isFollowingAuthor) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -1086,10 +1200,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
Text(stringResource(R.string.copy_text))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) {
Text(stringResource(R.string.copy_user_pubkey))
}
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) {
Text(stringResource(R.string.copy_note_id))
}
DropdownMenuItem(onClick = {
@@ -1110,7 +1224,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.quick_action_share))
}
Divider()
if (accountViewModel.isInPrivateBookmarks(note)) {
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_private_bookmarks))
}
@@ -1119,7 +1233,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.add_to_private_bookmarks))
}
}
if (accountViewModel.isInPublicBookmarks(note)) {
if (state.isPublicBookmarkNote) {
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
Text(stringResource(R.string.remove_from_public_bookmarks))
}
@@ -1133,7 +1247,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
Text(stringResource(R.string.broadcast))
}
Divider()
if (accountViewModel.isLoggedUser(note.author)) {
if (state.isLoggedUser) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}

View File

@@ -85,7 +85,7 @@ val externalLinkForNote = { note: Note ->
"https://habla.news/a/${note.address().toNAddr()}"
}
} else {
"https://snort.social/e/${note.idNote()}"
"https://snort.social/e/${note.toNEvent()}"
}
}
@@ -109,8 +109,6 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
var showDeleteAlertDialog by remember { mutableStateOf(false) }
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
@@ -129,6 +127,9 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (popupExpanded) {
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
Popup(onDismissRequest = onDismiss) {
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
@@ -151,13 +152,13 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}"))
clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}"))
showToast(R.string.copied_user_id_to_clipboard)
onDismiss()
}
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) {
clipboardManager.setText(AnnotatedString("@${note.idNote()}"))
clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}"))
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}

View File

@@ -36,6 +36,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.*
import kotlin.math.roundToInt
@@ -47,111 +49,153 @@ fun PollNote(
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.account = account
pollViewModel.load(zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
val color = if (
pollViewModel.consensusThreshold != null &&
optionTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f)
}
OptionNote(
poll_op.key,
poll_op.value,
pollViewModel,
baseNote,
accountViewModel,
canPreview,
backgroundColor,
navController
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (zappedNote.author == account.userProfile() || zappedNote.isZappedBy(account.userProfile()) == true) {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = optionTally
@Composable
private fun OptionNote(
optionNumber: Int,
optionText: String,
pollViewModel: PollNoteViewModel,
baseNote: Note,
accountViewModel: AccountViewModel,
canPreview: Boolean,
backgroundColor: Color,
navController: NavController
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
) {
if (!pollViewModel.canZap()) {
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
}
}
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
optionTally.second,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = optionTally.second,
progress = optionTally.first.toFloat()
)
Row(
verticalAlignment = Alignment.CenterVertically
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(horizontal = 10.dp)
.width(40.dp)
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
) {
Text(
text = "${(optionTally * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Text(
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
}
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
poll_op.key,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
poll_op.value,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
},
clickablePrepend = {
}
)
} else {
ZapVote(
baseNote,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
}
) {
TranslatableRichTextViewer(
optionText,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
navController
)
}
)
}
}
)
}
}
}
@@ -209,6 +253,16 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.poll_author_no_vote),
Toast.LENGTH_SHORT
)
.show()
}
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
scope.launch {
@@ -241,7 +295,8 @@ fun ZapVote(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = account.defaultZapType
)
}
} else {
@@ -279,7 +334,17 @@ fun ZapVote(
clickablePrepend()
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!optionWasZappedByLoggedInUser) {
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
}
}
}
if (optionWasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -306,8 +371,18 @@ fun ZapVote(
}
}
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
}
}
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
fontSize = 14.sp,
@@ -368,7 +443,8 @@ fun FilteredZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
account.defaultZapType
)
onDismiss()
}
@@ -393,7 +469,8 @@ fun FilteredZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
account.defaultZapType
)
onDismiss()
}

View File

@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.*
class PollNoteViewModel {
@@ -16,7 +17,9 @@ class PollNoteViewModel {
var valueMaximum: Int? = null
var valueMinimum: Int? = null
private var closedAt: Int? = null
var consensusThreshold: Float? = null
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
fun load(note: Note?) {
pollNote = note
@@ -24,8 +27,17 @@ class PollNoteViewModel {
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !note.isZappedBy(user, account)
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -73,47 +85,43 @@ class PollNoteViewModel {
return false
}
fun optionVoteTally(op: Int): Float {
val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal())
return if (tally.isNaN()) { // catch div by 0
0f
} else { tally }
}
private fun zappedVoteTotal(): Float {
var total = 0f
pollOptions?.keys?.forEach {
total += zappedPollOptionAmount(it).toFloat()
fun optionVoteTally(op: Int): BigDecimal {
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
return total
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author == user } == true) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.map {
val zappedOption = it.zappedPollOption()
if (zappedOption == option && it.zappedRequestAuthor() == user.pubkeyHex) {
return true
}
}
}
return false
return pollNote!!.zaps
.any {
val zapEvent = it.value?.event as? LnZapEvent
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
}
}
fun zappedPollOptionAmount(option: Int): BigDecimal {
return if (pollNote != null) {
pollNote!!.zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
val zappedOption = it.zappedPollOption()
if (zappedOption == option) {
it.amount
} else { null }
}.sumOf { it }
} else {
BigDecimal(0)
}
return pollNote?.zaps?.values?.sumOf {
val event = it?.event as? LnZapEvent
if (event?.zappedPollOption() == option) {
event.amount ?: BigDecimal(0)
} else {
BigDecimal(0)
}
} ?: BigDecimal(0)
}
fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
val zapEvent = (it?.event as? LnZapEvent)
if (zapEvent?.zappedPollOption() != null) {
zapEvent.amount ?: BigDecimal(0)
} else {
BigDecimal(0)
}
} ?: BigDecimal(0)
}
}

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -50,6 +49,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
@@ -63,9 +63,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.roundToInt
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -78,33 +79,35 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
}
Row(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToReplyTo = baseNote
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ReplyReaction(baseNote, accountViewModel) {
wantsToReplyTo = baseNote
}
}
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToQuote = baseNote
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
BoostReaction(baseNote, accountViewModel) {
wantsToQuote = baseNote
}
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
LikeReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ZapReaction(baseNote, accountViewModel)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
ViewCountReaction(baseNote.idHex)
}
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
ViewCountReaction(baseNote, Modifier.weight(1f))
}
}
@@ -112,14 +115,12 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
fun ReplyReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
showCounter: Boolean = true,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -139,29 +140,32 @@ fun ReplyReaction(
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint
)
ReplyIcon()
}
if (showCounter) {
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
@Composable
private fun ReplyIcon() {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
private fun BoostReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
@@ -227,16 +231,14 @@ private fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier
accountViewModel: AccountViewModel
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
@@ -285,8 +287,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -313,6 +314,16 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
@@ -362,7 +373,8 @@ fun ZapReaction(
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = account.defaultZapType
)
}
} else if (account.zapAmountChoices.size > 1) {
@@ -410,7 +422,7 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
if (wasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -454,13 +466,13 @@ fun ZapReaction(
}
@Composable
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
private fun ViewCountReaction(idHex: String) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.size(20.dp),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
@@ -470,10 +482,10 @@ private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier)
)
}
Row(modifier = textModifier) {
Row() {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
@@ -558,7 +570,8 @@ fun ZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
account.defaultZapType
)
onDismiss()
}
@@ -583,7 +596,8 @@ fun ZapAmountChoicePopup(
zapMessage,
context,
onError,
onProgress
onProgress,
account.defaultZapType
)
onDismiss()
}
@@ -604,9 +618,9 @@ fun showCount(count: Int?): String {
if (count == 0) return ""
return when {
count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
count >= 1000000 -> "${Math.round(count / 1000000f)}M"
count >= 1000 -> "${Math.round(count / 1000f)}k"
count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
count >= 1000 -> "${(count / 1000f).roundToInt()}k"
else -> "$count"
}
}

View File

@@ -16,14 +16,13 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.*
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Account, navController: NavController) {
ReplyInformation(replyTo, mentions, account) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) {
this.account = account
@@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() {
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.selectedZapType = account.defaultZapType
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
@@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() {
fun sendPost() {
account?.changeZapAmounts(amountSet)
account?.changeDefaultZapType(selectedZapType)
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
val pubkeyHex = try {
@@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() {
fun hasChanged(): Boolean {
return (
amountSet != account?.zapAmountChoices ||
selectedZapType != account?.defaultZapType ||
amountSet != account?.zapAmountChoices ||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
@@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val postViewModel: UpdateZapAmountViewModel = viewModel()
val uri = LocalUriHandler.current
val zapTypes = listOf(
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) {
postViewModel.load(account)
if (nip47uri != null) {
@@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
decorFitsSystemWindows = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Surface(
modifier = Modifier
.fillMaxWidth()
) {
Column(modifier = Modifier.padding(10.dp).imePadding()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
Spacer(modifier = Modifier.height(10.dp))
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
Row(
modifier = Modifier
.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1)
)
} ",
color = Color.White,
textAlign = TextAlign.Center
)
} ",
color = Color.White,
textAlign = TextAlign.Center
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextSpinner(
label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
postViewModel.selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle =
stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(
authTitle,
context,
scope,
keyguardLauncher
) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = stringResource(R.string.add), color = Color.White)
}
}
Divider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = 0.25.dp
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service),
Modifier.weight(1f)
)
IconButton(onClick = {
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
}) {
Icon(
painter = painterResource(R.drawable.alby),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
IconButton(onClick = {
qrScanning = true
}) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 14.sp
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
scope.launch {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = {
postViewModel.walletConnectPubkey = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true,
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
}
var showPassword by remember {
mutableStateOf(false)
}
val scope = rememberCoroutineScope()
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = {
if (!showPassword) {
authenticate(authTitle, context, scope, keyguardLauncher) {
showPassword = true
}
} else {
showPassword = false
}
}) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) {
stringResource(R.string.show_password)
} else {
stringResource(
R.string.hide_password
)
}
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
)
}
}
}

View File

@@ -5,12 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -27,15 +22,17 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ZapOptionstViewModel : ViewModel() {
private var account: Account? = null
var customAmount by mutableStateOf(TextFieldValue("1000"))
var customAmount by mutableStateOf(TextFieldValue("21"))
var customMessage by mutableStateOf(TextFieldValue(""))
fun load(account: Account) {
@@ -68,6 +65,19 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
postViewModel.load(account)
}
var zappingProgress by remember { mutableStateOf(0f) }
val zapTypes = listOf(
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -98,6 +108,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
postViewModel.customMessage.text,
context,
onError = {
zappingProgress = 0f
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT).show()
@@ -105,8 +116,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
onProgress = {
scope.launch(Dispatchers.Main) {
zappingProgress = it
}
}
},
zapType = selectedZapType
)
}
onClose()
@@ -139,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
TextSpinner(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
selectedZapType = zapTypes[it].first
},
modifier = Modifier
.weight(1f)
.padding(end = 5.dp)
)
}
Spacer(modifier = Modifier.height(5.dp))
Row(
modifier = Modifier
@@ -153,7 +178,15 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
) {
OutlinedTextField(
// stringResource(R.string.new_amount_in_sats
label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
label = {
if (selectedZapType == LnZapEvent.ZapType.PUBLIC || selectedZapType == LnZapEvent.ZapType.ANONYMOUS) {
Text(text = stringResource(id = R.string.custom_zaps_add_a_message))
} else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) {
Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private))
} else if (selectedZapType == LnZapEvent.ZapType.NONZAP) {
Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap))
}
},
value = postViewModel.customMessage,
onValueChange = {
postViewModel.customMessage = it
@@ -170,7 +203,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
},
singleLine = true,
modifier = Modifier
.padding(end = 10.dp)
.padding(end = 5.dp)
.weight(1f)
)
}

View File

@@ -43,7 +43,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard) {
LaunchedEffect(key1 = zapSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)

View File

@@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
@@ -15,7 +14,9 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.coroutines.CoroutineScope
@@ -30,7 +31,7 @@ import kotlin.time.measureTimedValue
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -46,28 +47,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
@Synchronized
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val notes = localFilter.loadTop()
val thisAccount = (dataSource as? NotificationFeedFilter)?.account
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (lastNotesCopy != null && oldNotesState is CardFeedState.Loaded) {
val newCards = convertToCard(notes.minus(lastNotesCopy))
if (newCards.isNotEmpty()) {
lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
val cards = convertToCard(notes)
lastNotes = notes
lastAccount = (dataSource as? NotificationFeedFilter)?.account
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed(cards)
}
}
private fun convertToCard(notes: List<Note>): List<Card> {
private fun convertToCard(notes: Collection<Note>): List<Card> {
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is ReactionEvent }
@@ -88,6 +89,8 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
if (zappedPost != null) {
val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
// var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event)
// zapRequest.event = newZapRequestEvent
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
}
} else {
@@ -159,7 +162,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
private fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { CardFeedState.Empty }
@@ -172,6 +175,28 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
val lastNotesCopy = if (thisAccount == lastAccount) lastNotes else null
if (lastNotesCopy != null && localFilter is AdditiveFeedFilter && oldNotesState is CardFeedState.Loaded) {
val filteredNewList = localFilter.applyFilter(newItems)
val actuallyNew = filteredNewList.minus(lastNotesCopy)
val newCards = convertToCard(actuallyNew)
if (newCards.isNotEmpty()) {
lastNotes = lastNotesCopy + newItems
lastAccount = (localFilter as? NotificationFeedFilter)?.account
updateFeed((oldNotesState.feed.value + newCards).distinctBy { it.id() }.sortedBy { it.createdAt() }.reversed())
}
} else {
// Refresh Everything
refreshSuspended()
}
}
@OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
@@ -181,13 +206,29 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
invalidateData()
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
val (value, elapsed) = measureTimedValue {
refreshFromOldState(it.flatten().toSet())
}
Log.d("Time", "${this.javaClass.simpleName} Card additive update $elapsed")
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
init {

View File

@@ -3,9 +3,10 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
@@ -65,7 +66,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
fun refreshSuspended() {
val notes = newListFromDataSource()
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
@@ -79,7 +80,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private fun updateFeed(notes: List<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
@@ -91,18 +92,41 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
updateFeed(newList)
} else {
// Refresh Everything
refreshSuspended()
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
invalidateData()
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
refreshFromOldState(it.flatten().toSet())
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
init {

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -32,7 +31,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun refreshSuspended() {
val notes = dataSource.loadTop()
val oldNotesState = feedContent.value
val oldNotesState = _feedContent.value
if (oldNotesState is LnZapFeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
@@ -46,7 +45,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
private fun updateFeed(notes: List<Pair<Note, Note>>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { LnZapFeedState.Empty }
} else if (currentState is LnZapFeedState.Loaded) {
@@ -68,7 +67,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
bundler.invalidate()
}
private val cacheListener: (LocalCacheState) -> Unit = {
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData()
}

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