Compare commits

...

167 Commits

Author SHA1 Message Date
Vitor Pamplona
fa6adcde75 v0.85.0 2024-03-04 09:10:11 -05:00
Vitor Pamplona
ad65e02fc9 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-04 09:09:21 -05:00
Vitor Pamplona
249ac4e039 Revert "v0.85.0"
This reverts commit a92ef7a375.
2024-03-04 09:08:20 -05:00
Vitor Pamplona
a92ef7a375 v0.85.0 2024-03-04 09:08:06 -05:00
Vitor Pamplona
e65faa2076 Merge pull request #794 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-03 11:14:10 -05:00
Crowdin Bot
5f53fc6aa9 New Crowdin translations by GitHub Action 2024-03-03 16:07:20 +00:00
Vitor Pamplona
218e133857 Merge pull request #793 from davotoula/update_translations
Updated translations. DE, SV, PT
2024-03-03 11:06:06 -05:00
David Kaspar
fcd61d87be Updated translations. DE, SV, PT 2024-03-03 09:28:10 +00:00
Vitor Pamplona
fb280f4880 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-02 16:18:33 -05:00
Vitor Pamplona
5342c4cfba updates mockk 2024-03-02 16:18:11 -05:00
Vitor Pamplona
dd106f315f Merge pull request #792 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-02 16:14:02 -05:00
Crowdin Bot
3ddd06fafc New Crowdin translations by GitHub Action 2024-03-02 21:13:28 +00:00
Vitor Pamplona
4fb68dd014 Fixes content title for the video playback notification 2024-03-02 16:11:01 -05:00
Vitor Pamplona
a301421915 Adds Home-only empty feed screen. 2024-03-02 15:57:49 -05:00
Vitor Pamplona
16c171ec40 Fixes quote-notes and reposted notes partially disappearing when they contain hidden users or words. 2024-03-02 12:37:02 -05:00
Vitor Pamplona
6725114b48 Avoids changing the showhidden value all the time, triggering recompositions. 2024-03-02 12:35:30 -05:00
Vitor Pamplona
861ad218c8 Fixes bug with NIP-11s with null kinds 2024-03-02 12:03:16 -05:00
Vitor Pamplona
b694ac7259 Visualizing proposed edits from other people 2024-03-01 21:15:43 -05:00
Vitor Pamplona
81cc985e3b Adds the ability to see and reply to Git Issues and Patches. 2024-03-01 18:09:15 -05:00
Vitor Pamplona
786802b708 Presents notifications when a person forks your content. 2024-03-01 17:07:19 -05:00
Vitor Pamplona
c53fc03157 Cache must update before updating the screen. 2024-03-01 16:52:18 -05:00
Vitor Pamplona
48a6923537 Forces edits to be from the same user. 2024-03-01 16:15:18 -05:00
Vitor Pamplona
c34811e05b Tries to highlight that there are two states here for Compose to observe. 2024-03-01 16:15:06 -05:00
Vitor Pamplona
3f500ef5b3 Make sure to add the forked author to the p-tag of a note. 2024-03-01 16:04:26 -05:00
Vitor Pamplona
cbce22c994 Fixes the alt tag 2024-03-01 15:46:58 -05:00
Vitor Pamplona
bc7a578cfe Making forks work with revisions. 2024-03-01 15:39:24 -05:00
Vitor Pamplona
af784a5bda Editting the current version of the post instead of only the original post. 2024-03-01 14:50:28 -05:00
Vitor Pamplona
b1a355691a Fixes another caching issue when multiple posts include the same poll. 2024-03-01 13:41:43 -05:00
Vitor Pamplona
4f2b2992e0 Merge pull request #791 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-01 12:41:09 -05:00
Crowdin Bot
77d30b6e7c New Crowdin translations by GitHub Action 2024-03-01 17:30:38 +00:00
Vitor Pamplona
228de99ccf Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-01 12:28:48 -05:00
Vitor Pamplona
dfec7ae7f5 Better aligns header elements 2024-03-01 12:28:42 -05:00
Vitor Pamplona
f8c4242843 Merge pull request #790 from davotoula/adding_translations
Updated translations. DE, SV, PT, CS
2024-03-01 12:17:18 -05:00
Vitor Pamplona
34150b6725 Merge branch 'main' into adding_translations 2024-03-01 12:17:12 -05:00
Vitor Pamplona
21a18cfa38 Rotates all versions of a note 2024-03-01 12:15:48 -05:00
David Kaspar
8fa6d1d490 Updated translations. DE, SV, PT, CS 2024-03-01 17:12:06 +00:00
Vitor Pamplona
c23510e05e Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-03-01 10:41:57 -05:00
Vitor Pamplona
1cc4fd5183 Closes popup when finished editing. 2024-03-01 10:40:43 -05:00
Vitor Pamplona
7468352795 Removes warnings from the quartz library. 2024-03-01 10:40:12 -05:00
Vitor Pamplona
238e577b93 avoids changing and reading the state at the same time 2024-03-01 10:39:49 -05:00
Vitor Pamplona
0653a69bbd Replaces deprecated put method 2024-03-01 10:39:32 -05:00
Vitor Pamplona
87fcba02b3 Move project to version catalogs 2024-03-01 10:38:03 -05:00
Vitor Pamplona
3fb8a5938f Updates to Android Studio Iguana 2024-03-01 08:03:58 -05:00
Vitor Pamplona
f2a9c2b34b Merge pull request #789 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-01 07:59:24 -05:00
Crowdin Bot
cf1e5f952d New Crowdin translations by GitHub Action 2024-03-01 12:57:52 +00:00
Vitor Pamplona
b6e096ec96 Only display edit option when looking at a text note. 2024-02-29 20:00:19 -05:00
Vitor Pamplona
ee4e20bac8 Fixing alt of git reply 2024-02-29 19:58:18 -05:00
Vitor Pamplona
4d2a7bbc32 Fixes Poll Caching problems. 2024-02-29 18:51:39 -05:00
Vitor Pamplona
600aab5e8d Reverts the ReactionRow change due to misalignments 2024-02-29 18:51:27 -05:00
Vitor Pamplona
1efef5887a Reduces the cache size for parsed posts. 2024-02-29 18:50:55 -05:00
Vitor Pamplona
3495866017 Improving cache of cashu tokens 2024-02-29 18:03:07 -05:00
Vitor Pamplona
0755bd2474 Improving the cache of LnInvoices. 2024-02-29 18:02:52 -05:00
Vitor Pamplona
d257f7e253 Fixes not being able to create an invoice because of an old cache of the lud16
Fixes inserting invoice in the cursor.
2024-02-29 17:35:44 -05:00
Vitor Pamplona
7497237763 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-29 17:06:22 -05:00
Vitor Pamplona
d506cf13a9 Adjustments to padding of the last element and the size of the relay box in kind 1 2024-02-29 17:05:41 -05:00
Vitor Pamplona
05454f5420 Merge pull request #788 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-29 15:10:18 -05:00
Crowdin Bot
8d8990899b New Crowdin translations by GitHub Action 2024-02-29 19:55:30 +00:00
Vitor Pamplona
fd37826663 Refactoring to improve legibility and compose stability of FhirResources 2024-02-29 14:52:56 -05:00
Vitor Pamplona
0084c2b532 Improves the speed of Location services. 2024-02-29 14:19:50 -05:00
Vitor Pamplona
c686e775bf Deletes unused code 2024-02-29 10:09:19 -05:00
Vitor Pamplona
6e0701b65d Removes unused kotlin serialization 2024-02-29 09:22:15 -05:00
Vitor Pamplona
417c29bd72 Updates Compose UI Version 2024-02-29 09:18:56 -05:00
Vitor Pamplona
3eb6983a94 Support for Kind 1 Edits 2024-02-28 17:42:06 -05:00
Vitor Pamplona
d4ba135368 Second embed bundle tests with nsecs 2024-02-28 16:54:28 -05:00
Vitor Pamplona
7f35d7d416 Improves rendering of prescriptions 2024-02-28 16:52:33 -05:00
Vitor Pamplona
9f4a30657b Fixes Ots Observation check 2024-02-28 16:51:14 -05:00
Vitor Pamplona
86aab2e842 Fixes DM chatroom edit button 2024-02-28 16:50:58 -05:00
Vitor Pamplona
73e3b60721 Fixes missing nsec processing 2024-02-28 16:50:23 -05:00
Vitor Pamplona
fdb0830d1b Refactors extra chars for Bech Links 2024-02-28 11:23:56 -05:00
Vitor Pamplona
bc833fadcf Fixes event loading for nembeds 2024-02-28 11:23:05 -05:00
Vitor Pamplona
d36efd6a2c Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-27 20:56:31 -05:00
Vitor Pamplona
0d92384d8c Fixes OTS test cases 2024-02-27 19:16:06 -05:00
Vitor Pamplona
466929a0f7 Merge pull request #787 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-27 18:56:30 -05:00
Crowdin Bot
85059d775b New Crowdin translations by GitHub Action 2024-02-27 23:52:02 +00:00
Vitor Pamplona
328ed226c6 Basic support for FHIR payloads.
Support for nembed
2024-02-27 18:50:20 -05:00
Vitor Pamplona
e305f3c198 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-27 15:40:49 -05:00
Vitor Pamplona
f78a252c8f - Adds nembed
- Redesigns NIP-19 returns to allow nembeds and give more nuance to the UI.
2024-02-27 15:39:15 -05:00
Vitor Pamplona
6e65bdbb91 Merge pull request #786 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-27 10:40:49 -05:00
Crowdin Bot
9639514a82 New Crowdin translations by GitHub Action 2024-02-27 15:37:25 +00:00
Vitor Pamplona
87fabd54de Completed OTS Support 2024-02-27 10:34:49 -05:00
Vitor Pamplona
529403d92f Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-26 20:52:24 -05:00
Vitor Pamplona
e2d86c1727 Adds a new report option as other 2024-02-26 20:52:15 -05:00
Vitor Pamplona
11ff0736e4 Speeding up the loop through local cache. 2024-02-26 20:51:59 -05:00
Vitor Pamplona
fbc63a6df1 Merge pull request #784 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-26 19:04:10 -05:00
Crowdin Bot
7dccac41e9 New Crowdin translations by GitHub Action 2024-02-26 23:53:45 +00:00
Vitor Pamplona
8ebad524ff Adds local cache to wait for OTS confirmations
Presents OTS pending and confirmations on the screen
Adds Menu item to OTS any post.
2024-02-26 18:51:23 -05:00
Vitor Pamplona
bf67ff64b4 Moves the image preview on new posts to the top of additional options for the post. 2024-02-26 15:47:44 -05:00
Vitor Pamplona
85ecaf18cc Sets split zaps for all quotes and forks 2024-02-26 15:47:22 -05:00
Vitor Pamplona
3c36f52baf Visual support for Kind1 forks. 2024-02-26 15:17:17 -05:00
Vitor Pamplona
782926fbea Separates the q filter from the e filter to avoid and moves q to the last position since it can be cut off and won't affect the app. 2024-02-25 18:24:50 -05:00
Vitor Pamplona
21d141c5c2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-25 17:53:23 -05:00
Vitor Pamplona
de17aab7ab Fixing stack overflow with more than 200 zaps in a single note. 2024-02-25 17:53:15 -05:00
Vitor Pamplona
d1b0707b54 Merge pull request #783 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-25 17:13:49 -05:00
Crowdin Bot
2ea80272e3 New Crowdin translations by GitHub Action 2024-02-25 22:06:59 +00:00
Vitor Pamplona
6a7d33e58f - Generalizing OTS Calendar access
- Developing OTS access points with OkHttp
2024-02-25 17:04:51 -05:00
Vitor Pamplona
880915e702 Better explains why the fork of javaOTS was needed. 2024-02-25 15:43:59 -05:00
Vitor Pamplona
d6fd407632 Moves external signer from org.json to jackson parser 2024-02-25 15:43:42 -05:00
Vitor Pamplona
1c0854eeaa Moves from org.json to jackson in OTS replies 2024-02-25 15:43:18 -05:00
Vitor Pamplona
9eefb65047 Generalizes the interface for block explorers in OTS 2024-02-25 15:43:01 -05:00
Vitor Pamplona
31c9041461 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-25 11:50:01 -05:00
Vitor Pamplona
c4d6b1a5d1 Adds support for OpenTimestamps in Quartz 2024-02-25 11:49:51 -05:00
Vitor Pamplona
ad03d355b0 Adds internet for testing of OTS attestations 2024-02-25 11:45:59 -05:00
Vitor Pamplona
5ad86e5604 Adds support for the q tag together with the support for the mention marker. 2024-02-25 11:40:39 -05:00
Vitor Pamplona
6afd9a0191 Adds note on why the libscrypt was removed. 2024-02-25 11:39:48 -05:00
Vitor Pamplona
58ff0b5500 Merge pull request #782 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-24 09:18:08 -05:00
Crowdin Bot
3f33871c9c New Crowdin translations by GitHub Action 2024-02-24 14:16:45 +00:00
Vitor Pamplona
ca4cc93b31 Fix Quartz use 2024-02-24 09:15:41 -05:00
Vitor Pamplona
49c2d4f486 Adding Basic Git Rendering support. 2024-02-23 22:09:06 -05:00
Vitor Pamplona
b9c3123f18 Faster Longform tag filters 2024-02-23 18:08:55 -05:00
Vitor Pamplona
452911bf27 Removes the need to specify nulls for new TextNote creations 2024-02-23 18:08:30 -05:00
Vitor Pamplona
173e82d236 Adds Wikipage Kind 2024-02-23 17:55:39 -05:00
Vitor Pamplona
2819c71e85 Updates Coil and Google Service dependencies 2024-02-23 16:59:47 -05:00
Vitor Pamplona
3eac579638 Fixing Format 2024-02-23 14:29:14 -05:00
Vitor Pamplona
d606cef90e Adds Jitpack instructions for Quartz 2024-02-23 14:27:20 -05:00
Vitor Pamplona
de8870f240 Merge pull request #780 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-23 13:45:11 -05:00
Crowdin Bot
13031223a4 New Crowdin translations by GitHub Action 2024-02-23 18:22:53 +00:00
Vitor Pamplona
553500460a Merge pull request #779 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-23 13:21:41 -05:00
Vitor Pamplona
a7e601beee Keeping signing and util classes in the final jar 2024-02-23 13:21:25 -05:00
Crowdin Bot
d21792a46f New Crowdin translations by GitHub Action 2024-02-23 17:17:02 +00:00
Vitor Pamplona
b8b9aaf4aa Adds time utils tests to see if jitpack exports it 2024-02-23 12:15:20 -05:00
Vitor Pamplona
dd160ecb71 Improves relay list NIP-11 caching, updates NIP-11 properties in the document and adds icon rendering from the NIP11 document. 2024-02-22 18:43:46 -05:00
Vitor Pamplona
2ee4b24c9b Moves Relay information to Quartz
Renames Nip19 object for better readability
2024-02-22 16:08:36 -05:00
Vitor Pamplona
606c483d09 Moves Wallet Connect to Quartz 2024-02-22 15:40:07 -05:00
Vitor Pamplona
b1d1c4089c Speeds up some of image calculations 2024-02-22 14:46:18 -05:00
Vitor Pamplona
5740b75a64 Adds a few more benchmarks 2024-02-22 14:05:47 -05:00
Vitor Pamplona
8c9971d9f2 Moves benchmark tests to the benchmark release type 2024-02-22 13:59:59 -05:00
Vitor Pamplona
860c3773c3 Moves emoji parsers to commons and 2024-02-22 13:56:56 -05:00
Vitor Pamplona
8ff4ac3709 Inserts uploaded URLs where the cursor is and not at the end of the file. 2024-02-22 09:54:21 -05:00
Vitor Pamplona
5b77e39c8b Improves the speed of the text parser. 2024-02-21 19:18:30 -05:00
Vitor Pamplona
5886c866d3 Makes the name of the test follow the refactored name 2024-02-21 16:24:06 -05:00
Vitor Pamplona
96f29fc5ca Refactoring some names for the parsers. 2024-02-21 16:22:17 -05:00
Vitor Pamplona
90175198f0 Preparing to move Kind1 content parsers to commons. 2024-02-21 15:25:40 -05:00
Vitor Pamplona
4b9a55e178 Increasing the speed of the Robohash SVG to buffer function 2024-02-21 13:51:27 -05:00
Vitor Pamplona
0429c4dc3a Faster comparisons on GiftWrap Benchmarks 2024-02-21 11:25:08 -05:00
Vitor Pamplona
e3f3350c85 Fixes Robohash test 2024-02-21 11:24:51 -05:00
Vitor Pamplona
5cd1c5ab90 Faster hex validation 2024-02-21 11:23:32 -05:00
Vitor Pamplona
d982f029e4 Fixes Ukrainian translation 2024-02-21 10:22:20 -05:00
Vitor Pamplona
f44bc02767 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-21 10:13:18 -05:00
Vitor Pamplona
2e096069b9 Merge pull request #778 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-21 10:04:53 -05:00
Vitor Pamplona
f10dd38126 refactoring on method names to make them clearer 2024-02-21 10:04:40 -05:00
Crowdin Bot
74b1cad578 New Crowdin translations by GitHub Action 2024-02-21 15:02:57 +00:00
Vitor Pamplona
dbcf59779e Fix benchmark test 2024-02-21 09:59:39 -05:00
Vitor Pamplona
f4cee92702 Fixes the crash on images not being present in the image pager. 2024-02-20 16:51:18 -05:00
Vitor Pamplona
b70407f447 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-20 15:52:32 -05:00
Vitor Pamplona
444857ad95 Updating Vico 2024-02-20 15:52:25 -05:00
Vitor Pamplona
ae291d278f Fixes text's vertical alignment when emoji's are present. 2024-02-20 12:28:46 -05:00
Vitor Pamplona
0d44c44c6e Adds release note ID to gradle. 2024-02-20 11:30:27 -05:00
Vitor Pamplona
a03f9d2b98 Fixes position of the notification dot 2024-02-20 11:30:11 -05:00
Vitor Pamplona
49a090da00 Merge pull request #777 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-20 11:00:50 -05:00
Crowdin Bot
c8d11fdcac New Crowdin translations by GitHub Action 2024-02-20 15:59:43 +00:00
Vitor Pamplona
33342b85d1 Merge pull request #776 from davotoula/update_translations
Updated translations. DE, SV, PT, CS
2024-02-20 10:58:17 -05:00
David Kaspar
a148da4b7b Updated translations. DE, SV, PT, CS 2024-02-20 11:40:16 +00:00
Vitor Pamplona
1b93fc57cb Creates a way to ask for donations in the Notification page. 2024-02-19 18:57:21 -05:00
Vitor Pamplona
2514b74883 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-19 18:55:38 -05:00
Vitor Pamplona
a97ade41fa adds a release drafter to test 2024-02-19 18:55:29 -05:00
Vitor Pamplona
e44ee1e549 Merge pull request #775 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-02-19 08:31:44 -05:00
Crowdin Bot
b170d04d5e New Crowdin translations by GitHub Action 2024-02-19 13:20:13 +00:00
Vitor Pamplona
dc55d71486 Removing the need to recreate modifiers on BottomBar 2024-02-19 08:17:44 -05:00
Vitor Pamplona
dd071dc65a Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-02-17 13:54:45 -05:00
Vitor Pamplona
dd7427971c refactoring 2024-02-17 13:53:53 -05:00
Vitor Pamplona
d715ea44df Typo 2024-02-17 12:39:48 -05:00
Vitor Pamplona
7af2fcdb27 Adds playstore badge 2024-02-17 11:51:42 -05:00
Vitor Pamplona
879f7cf8e3 Smaller download badges 2024-02-17 11:29:18 -05:00
Vitor Pamplona
88033c2acd Update README.md 2024-02-17 11:16:45 -05:00
Vitor Pamplona
e7fc5545ba Update README.md 2024-02-17 11:15:19 -05:00
Vitor Pamplona
e7240c0acf Using an SVG as readme logo 2024-02-17 11:09:40 -05:00
Vitor Pamplona
1fa8cd95dc Improves Layout of Readme 2024-02-17 11:05:40 -05:00
242 changed files with 16120 additions and 3122 deletions

View File

@@ -227,4 +227,9 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
asset_content_type: application/zip
- name: Drafts a description for the release
uses: release-drafter/release-drafter@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

120
README.md
View File

@@ -1,22 +1,42 @@
# Amethyst: Nostr client for Android
<div align="center">
<a href="https://amethyst.social">
<img src="./docs/design/3rd%20Logo%20-%20Zitron/amethyst.svg" alt="Amethyst Logo" title="Amethyst logo" width="80"/>
</a>
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px"/>
# Amethyst
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
## Nostr Client for Android
Join the social network you control.
[![GitHub downloads](https://img.shields.io/github/downloads/vitorpamplona/amethyst/total?label=Downloads&labelColor=27303D&color=0D1117&logo=github&logoColor=FFFFFF&style=flat)](https://github.com/vitorpamplona/amethyst/releases)
[![PlayStore downloads](https://img.shields.io/endpoint?color=green&logo=google-play&logoColor=green&url=https%3A%2F%2Fplay.cuzi.workers.dev%2Fplay%3Fi%3Dcom.vitorpamplona.amethyst%26gl%3DUS%26hl%3Den%26l%3DPlayStore%26m%3D%24shortinstalls)](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
[![Last Version](https://img.shields.io/github/release/vitorpamplona/amethyst.svg?maxAge=3600&label=Stable&labelColor=06599d&color=043b69)](https://github.com/vitorpamplona/amethyst)
[![JitPack version](https://jitpack.io/v/vitorpamplona/amethyst.svg)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
height="80">](https://github.com/ImranR98/Obtainium)
height="70">](https://github.com/ImranR98/Obtainium)
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
height="80">](https://github.com/vitorpamplona/amethyst/releases)
height="70">](https://github.com/vitorpamplona/amethyst/releases)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
height="70">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
height="70">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
# Current Features
</div>
## Supported Features
<img align="right" src="./docs/screenshots/home.png" data-canonical-src="./docs/screenshots/home.png" width="350px">
- [x] Events / Relay Subscriptions (NIP-01)
- [x] Follow List (NIP-02)
@@ -103,31 +123,41 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [ ] Workspaces
- [ ] Infinity Scroll
## Privacy and Information Permanence
Relays know your IP address, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) except for Private Zaps and Private DMs. While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN or Tor) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
Information shared on Nostr can be re-broadcasted to other servers and should be assumed permanent for privacy purposes. There is no way to guarantee the deletion of any content once posted.
# Development Overview
## Overall Architecture
This repository is split between Amethyst and Quartz:
- Amethyst is a native Android app made with Kotlin and Jetpack Compose.
- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients.
This is a native Android app made with Kotlin and Jetpack Compose.
The app uses a modified version of the [nostrpostrlib](https://github.com/Giszmo/NostrPostr/tree/master/nostrpostrlib) to talk to Nostr relays.
The overall architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays,
and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph.
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData objects to
The repository layer stores Nostr Events as Notes and Users separately. Those classes use LiveData and Flow objects to
allow the UI and other parts of the app to subscribe to each Note/User and receive updates when they happen.
They are also responsible for updating viewModels when needed. Filters react to changes in the screen. As the user
sees different Events, the Datasource classes are used to receive more information about those particular Events.
They are also responsible for updating viewModels when needed. As the user scrolls through Events, the Datasource classes
are updated to receive more information about those particular Events.
Most of the UI is reactive to changes in the repository classes. The service layer assembles Nostr filters for each need of the app,
receives the data from the Relay, and sends it to the repository. Connection with relays is never closed during the use of the app.
The UI receives a notification that objects have been updated. Instances of User and Notes are mutable directly.
There will never be two Notes with the same ID or two User instances with the same pubkey.
Lastly, the user's account information (priv key/pub key) is stored in the Android KeyStore for security.
Lastly, the user's account information (private key/pub key) is stored in the Android KeyStore for security.
## Setup
Make sure to have the following pre-requisites installed:
1. Java 17
1. Java 17+
2. Android Studio
3. Android 8.0+ Phone or Emulation setup
@@ -136,7 +166,7 @@ Fork and clone this repository and import it into Android Studio
git clone https://github.com/vitorpamplona/amethyst.git
```
Use one of the Android Studio builds to install and run the app in your device or a simulator.
Use an Android Studio build action to install and run the app on your device or a simulator.
## Building
Build the app:
@@ -168,19 +198,7 @@ For the Play build:
./gradlew installPlayDebug
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are also very welcome.
Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
By contributing to this repository, you agree to license your work under the MIT license. Any work
contributed where you are not the original author must contain its license header with the original
author(s) and source.
## How to Deploy
## Deploying
1. Generate a new signing key
```
@@ -199,23 +217,35 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
7. Add your CHANGE LOG to the description of the new release
8. Download the `aab` file and upload it to the PlayStore.
# Privacy on Relays & nostr
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
## Using the Quartz library
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
Setup [JitPack.io](https://jitpack.io/#vitorpamplona/amethyst/v0.84.3) to your build file
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
Add `maven { url 'https://jitpack.io' }` to settings.gradle at the end of repositories:
# DM Privacy #
While the content of direct messages (DMs) is only visible to you and your DM counterparty, everyone can see when you and your counterparty DM each other.
```gradle
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
```
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Information that you share publicly is visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
Add the dependency
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee edit or deletion of any content once posted.
```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:v0.84.3')
```
## Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) here are very welcome. Translations can be provided via [Crowdin](https://crowdin.com/project/amethyst-social)
You can also send patches through Nostr using [GitStr](https://github.com/fiatjaf/gitstr) to [this nostr address](https://patch34.pages.dev/naddr1qqyxzmt9w358jum5qyg8v6t5daezumn0wd68yvfwvdhk6qg7waehxw309ahx7um5wgkhqatz9emk2mrvdaexgetj9ehx2ap0qy2hwumn8ghj7un9d3shjtnwdaehgu3wvfnj7q3qgcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqxpqqqpmej720gac)
By contributing to this repository, you agree to license your work under the MIT license. Any work contributed where you are not the original author must contain its license header with the original author(s) and source.
# Screenshots
@@ -223,7 +253,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
## Contributors
# Contributors
<a align="center" href="https://github.com/vitorpamplona/amethyst/graphs/contributors">
<img src="https://contrib.rocks/image?repo=vitorpamplona/amethyst" />
@@ -231,6 +261,7 @@ Information shared on nostr should be assumed permanent for privacy purposes. Th
# MIT License
<pre>
Copyright (c) 2023 Vitor Pamplona
Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -250,3 +281,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</pre>

View File

@@ -1,7 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.gms.google-services'
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.googleServices)
}
android {
@@ -12,8 +12,9 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 358
versionName "0.84.3"
versionCode 359
versionName "0.85.0"
buildConfigField "String", "RELEASE_NOTES_ID", "\"d8da33fd13d129d86c53564aedefafbe3716f007c520431be4a8e488d3925afb\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -144,14 +145,13 @@ android {
// Should match compose version : https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion "1.5.8"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
excludes += ['/META-INF/{AL2.0,LGPL2.1}', '**/libscrypt.dylib']
}
exclude '**/libscrypt.dylib'
}
lint {
disable 'MissingTranslation'
}
@@ -164,75 +164,78 @@ android {
dependencies {
implementation project(path: ':quartz')
implementation project(path: ':commons')
implementation "androidx.core:core-ktx:$core_ktx_version"
implementation 'androidx.activity:activity-compose:1.8.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation libs.androidx.core.ktx
implementation libs.androidx.activity.compose
implementation platform(libs.androidx.compose.bom)
implementation libs.androidx.ui
implementation libs.androidx.ui.graphics
implementation libs.androidx.ui.tooling.preview
// Needs this to open gallery / image upload
implementation "androidx.fragment:fragment-ktx:$fragment_version"
implementation libs.androidx.fragment.ktx
// Navigation
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation libs.androidx.navigation.compose
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
implementation libs.androidx.runtime.livedata
// Material 3 Design
implementation "androidx.compose.material3:material3:${material3_version}"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
implementation libs.androidx.material3
implementation libs.androidx.material.icons
// Adaptive Layout / Two Pane
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
implementation 'com.google.accompanist:accompanist-adaptive:0.34.0'
implementation libs.androidx.material3.windowSize
implementation libs.accompanist.adaptive
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.lifecycle.livedata.ktx
// Zoomable images
implementation 'net.engawapg.lib:zoomable:1.6.0'
implementation libs.zoomable
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
implementation libs.androidx.biometric.ktx
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.12'
implementation libs.okhttp
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.17.2'
implementation libs.jsoup
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
implementation libs.androidx.security.crypto.ktx
// view videos
implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.exoplayer.hls
implementation libs.androidx.media3.ui
implementation libs.androidx.media3.session
// important for proxy / tor
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
implementation libs.androidx.media3.datasource.okhttp
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
implementation libs.coil.compose
// view gifs
implementation "io.coil-kt:coil-gif:$coil_version"
implementation libs.coil.gif
// view svgs
implementation "io.coil-kt:coil-svg:$coil_version"
implementation libs.coil.svg
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
implementation libs.trbl.blurhash
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation libs.accompanist.permissions
// For QR generation
implementation 'com.google.zxing:core:3.5.3'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
implementation libs.zxing
implementation libs.zxing.embedded
// Markdown
//implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
@@ -240,51 +243,50 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material3:48702a8ced')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:48702a8ced')
implementation libs.markdown.ui
implementation libs.markdown.ui.material3
implementation libs.markdown.commonmark
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation libs.androidx.appcompat
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
playImplementation libs.google.mlkit.language.id
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.2'
playImplementation libs.google.mlkit.translate
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.7.2')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
playImplementation platform(libs.firebase.bom)
playImplementation libs.firebase.messaging
//PushNotifications(FDroid)
fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
fdroidImplementation libs.unifiedpush
// Charts
implementation "com.patrykandpatrick.vico:core:${vico_version}"
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
implementation libs.vico.charts.core
implementation libs.vico.charts.compose
implementation libs.vico.charts.views
implementation libs.vico.charts.m3
// GeoHash
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
implementation libs.drfonfon.geohash
// Waveform visualizer
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
implementation libs.audiowaveform
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.2'
implementation libs.abedElazizShe.image.compressor
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
implementation libs.zelory.video.compressor
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.9'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha03'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.2.0-alpha03'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
testImplementation libs.junit
testImplementation libs.mockk
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.junit.ktx
androidTestImplementation libs.androidx.espresso.core
debugImplementation libs.androidx.ui.tooling
debugImplementation libs.androidx.ui.test.manifest
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import com.vitorpamplona.quartz.signers.NostrSignerInternal
import junit.framework.TestCase.assertEquals
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class OkHttpOtsTest {
val otsEvent = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqCiiW0FlsU9lqK5f1A+cL6CGJ1Ah4V/A1yNJY/stUE3wECJz6ng/QxU5Z6xwaMx97qkI//AQqJv8bEMrGTplGWRv5qm4DgjxIHkcQqzpL0Fjr9VBAAijDe0IsQYpOhw1SIjZIgQa6i16CPEEZck7CvAIxR0AloJzCZoAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEOwPtjIkKI1hmtv9t1kuxZcI8QRlyTsK8Ahl0wrCSggZzgCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wEE1dVGa8JCuf2ek0c5ybDKII8SCBoVz8Sal45Kd1O8STWIGJTcl5JPtAZBZitqk3BE9MqAjxBGXJOwrwCHoGVgAZi9q9AIPf4w0u+QyOKShodHRwczovL2Zpbm5leS5jYWxlbmRhci5ldGVybml0eXdhbGwuY29t8BAFZFXFYg7DJJ0OzjmJ0FKWCPEEZck7CvAI0M49IcBR5bf/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPEgoE3IfYTmxxo4W/x/QYp/NGX6Wu93gSQkwbpjpOhZORcI8SDWLLurVQaXHdUuwivCfTfuxYCaq+AzypSGqLDAVocrEgjwIBfgjta16y13Gp4etQOCa9YiKEcM+/9AieG/vZolr3IDCPAgMR2zFCb384CEi8tVuI2fHgLT3I9zpe7oqJTzCcEqxWEI8SAJSdgeeosr7IxdOt8r7f0ipWc8FI6GAhgep8zSRgWikAjxIGxYmtCsC79Tx4z4YsT1WuMo+ycMkwhGQsQltF597cchCPAgCLrBf9vR1Aex6yY+vSkXAvLjMKdMqM/a1g8zNPwLeJcI8SBDCbTk4CczTuiIyZeUyYRVh31BZdjaSd2nU/pBQxu+6QjwIOwqE9/WqGC6CHH0i+tr7edvYX5PstSDf08KmnMqsqCoCPEgQIdEBg358vfek3Qjfyrgl51iCU6WUWmThsGLPDTcB0QI8SAX4dp64iI8pBx+zBqAQwUN6XgZ1cEfT8+2vha/9I1vzwjxWQEAAAAB8YJxvxJJth8OnxIV6UOXveIZAcJPTHcAkWgnucpuYqYAAAAAAP3///8CRhoMAAAAAAAWABTUUZK82uAvbU3vVyaaPIOddZBicwAAAAAAAAAAImog8ARCqgwACAjwIB9TcMDLhzgeS1Uw647lNvCfWECkkUvfrrOe6nay0sGdCAjwICYfs90sbPggoMICyOHGYbmOzop2L9mlnh4xqiBLY7yPCAjxINDiVWOBHnRmGJleQdB9myvJAJbNJ9kciZlTOkgJy89mCAjwIHfxqDLdwycj1Vtyth2CaSDdLQwiey9oV6Xov4stLpWNCAjxIGurJYpKJnKp9+y7MAdC+gXgHOiAu5P3RRUFW9l5hGaCCAjxICXqs9hdY0QMP/MNeqlt6s7xaIYtEXZ1CLvou5gaZNEICAjxIHdYxVeI76NXbT2zHcv6lw+v819Ooib7KWxc1GAsiX2fCAjwICPWdi3uBXOlIdmYi+V9C7wAqLyGE4DMoHD+GtvLizqiCAjwIOF2vENtWN5okEMMS+JSf1SGTY9yYP9j0JjXLbC1s+N1CAjxINWsgCtsPxhRNbe372k8/20WDbiL9e8934hGF256DvRECAjwII3mi+Li06j10ORxg0dYMkcsyGb115Jiqq1YEV3K/u+aCAgABYiWDXPXGQEDw9Qy\",\"created_at\":1707690688,\"id\":\"759f9da5846e936fab06766a524b36ba71c03bbc69ad0944fb8ee4bb1f3dd705\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"07c7896c8cbb97b5d7483097590c9d31b73f35c1ad9e752002bb5c1776cbd852e1d32704333d6930c9bc3e40f8b899a1f2e9f91cc3bf797d86acdecba7792576\",\"tags\":[[\"e\",\"a828a25b4165b14f65a8ae5fd40f9c2fa086275021e15fc0d7234963fb2d504d\"],[\"p\",\"595ca8eaace5899cb6ab7e2542bfc972136376f2eabc09287f1857eb8f167e53\"],[\"alt\",\"NIP-03 time stamp\"]]}"
val otsEvent2 = "{\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwENyCNtiEN98IzIZgEu3cl6YI//AQ6TkSRd3BTGhDHCK1KkJc+AjxIAHaizG++NNL3Vm13BJrIhT7Br6tEYpb0TVRGaadgiUMCPAgOSDREH9v1Y50UHu79LfC4Lcd9WklQJzRQpw+Unb/pyII8QRltDD58AgqrxfAVrLw7QCD3+MNLvkMji4taHR0cHM6Ly9hbGljZS5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQQMq/CLpGwY60nmddPS7OVgjxIDKxqd9nl+Mej41vP52Wd7gv7004r3n1rFGDObS8icRvCPAgH9TB/kwvXJEEw+h9Ce6fLaI3MORjtTEge0GbAefT6W4I8QRltDD58AhRcoU3gAo/swCD3+MNLvkMjiwraHR0cHM6Ly9ib2IuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ//wECWtWsKo0uvSr8BYonjs3DEI8CBlsh2ng1Spl0K4oStYElGuMJsjd2uo5nXB+apo5A7ipwjxIM8oxynBwNA+QS/X7Ebtl1kyhFgfoOQioASNfCBzZ4gaCPEEZbQw+fAId6Yd5cw5gioAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb23wEJmPzXQbxv0AFTIyjTWjMskI8CAurbkrfrBtlinZXSDxj+m/oIkze57hGjTSxu1Xs87XYQjwIPk/LMD0zIgKoEE2dfeoYrrdHuO6dwmghTwUFajH2QzkCPEEZbQw+fAIE+Pq1/Wmdpj/AIPf4w0u+QyOIyJodHRwczovL2J0Yy5jYWxlbmRhci5jYXRhbGxheHkuY29tCPAgB2CbqkV7VpjRKIl3Ea6cBmB/EHcSN/YCgcc1E+mc07QI8CAfpkZ2Hh4Rukz3x4il3tZqQtlDlbna+I6so2t2YSEmMQjwIJOv32jbsMa2HJwpleRCKLEhgYOoHCSfpv1ZO0YNNNFsCPAgLMM7eFfCjokQfU4gdU5WpG/wBLkO9lDRF0GktL6ujt8I8SCRxJ0bC1PQ8qFmI/1jh8AS5d1/6VRJNMt1Hz41QmNr3QjwIBmgrKBF+OZ3y+XOMv2E7IZ4WwLr2u2H+ehsBfy7cPlICPEgm4ZMCSXzZVWu40d+zk2edaur6KOauo8X7V2KaFBR1VoI8SBKVVOiyq6IFqGn/15kLwk7L8upMAIZ0znjhYxYqSTQCQjwIMBD1twPZ33GxbwTiuOCeJPkoP++6R2wYpCii8UBTdgwCPAg84VkgMXwrt2xxRoeC1/6CtsFctki+w3m8Rs5/6g/IhEI8CCnzZQDhJyicX7bS7U8PMUObuC9Y4TXe+4THoXBMMXkxwjxIMZ0oAvshpcwowR3qPEDbwKZ6B4NPSU4Hz/+4PnD74gnCPAgIfLZEKqAvkMNXfakXoNq1UVqGSzL4Z86z5GzUfbvw8UI8SC8KoIeLvjd4vJ/xhNVphakPRd80YKeNkeYEuVH8k2EtAjwIPRtinLLxzt8iuw0XZtpTDzEstZOTNYVm+Bi3fEzdeIuCPFZAQAAAAE8vsasINN5DKon0KakX2HNdCB126ZLKXrw4PfvyEfqbwAAAAAA/f///wLPGw8AAAAAABYAFCvgxlh6msa4ZOtgvlc5KiZCx7IvAAAAAAAAAAAiaiDwBKygDAAICPEgB/2DJ3s6gMky/PceGZocTFRXjZUiCCAhHGYwQk/8yrUICPEgDuSd6+PJHUMuEHyLcKFxw7xfvRHRfInjkV3/Zy3BxqAICPEgZDgQ+4VXzlOIkGoO8EVxDgs2cWaeh4EEiaqa/y50gKAICPAgFI+zIuYcMF69GmPQVsXa9oy8eng7MeRZdIxArQyeX3oICPEgX5HYIuImpiSTEapgEssEW4l+W+4aRfNCG3pZf7z0hCoICPEgPkAbOSjFdtS4NT7MXgMYVQoQhI1JZtdFxUu4J3NTt7IICPEguW3qyuyGjctu5d9rM9P9ZCs/ZK4vAc+z21b9ygklgWAICPEgu4e2645xtvGhI1Zzuiv23vRhwE8uC9vj1TAgNg/C8UcICPAgwoQX8X0nY4HoQLRsJ0z8JCWQDzRh2iL2QXEb8z3gbjIICPAgzTwlPRtStsLJWhz3Q/0l8tMnrPSHVuh+zCiGk95dW2MICPAgGcBEuYZyzFNapHOfnJ9Q515QzO2VbIRhlVI0vIhd4jwICPAgidRMoM2pA+KmVJenVrLcbollsbUg9lL9bmv1C1dSxswICPAgZinGakwhbHdanTaRJeBkEUlbhfNokvj8b5KneyG+wzIICAAFiJYNc9cZAQOtwTI=\",\"created_at\":1706324334,\"id\":\"2ad074ddb7724eb13b4244b49cf2321b1057f37fdf8ce102e6329b839cf763a9\",\"kind\":1040,\"pubkey\":\"82fbb08c8ef45c4d71c88368d0ae805bc62fb92f166ab04a0b7a0c83d8cbc29a\",\"sig\":\"ad7274bb32ba9e9cfdbd52f4887e8a2fda1047c75a7185b2ab7ff254ebac14ed48a2b60737494d655e24c9400eeeec7e29293a77bfcaafaecd94b350c9a2c22b\",\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"p\",\"c31e22c3715c1bde5608b7e0d04904f22f5fc453ba1806d21c9f2382e1e58c6c\"],[\"alt\",\"NIP-03 time stamp\"]]}"
val otsPendingEvent = "{\"id\":\"12fa15ad4b4cf9dc5940389325b69b93c5c1f59c049c701ee669b275299fdaf1\",\"pubkey\":\"dcaa6c8a2f47b6fef4a34b20e8843c59dbe7c5f07a402338c09fd147dd01d22b\",\"created_at\":1708877521,\"kind\":1040,\"tags\":[[\"e\",\"a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6\"],[\"alt\",\"Opentimestamps Attestation\"]],\"content\":\"AE9wZW5UaW1lc3RhbXBzAABQcm9vZgC/ieLohOiSlAEIqGNPU2jhd4no+zg2ytDkuf5PIoivr8KHI8BL68aKGNbwELidvzr0usf55CkpKf6OABQI//AQK3sWd2tq+7KO8YNJIARJugjxBGXbZtPwCL0H4/7GL5+SAIPf4w0u+QyOLCtodHRwczovL2JvYi5idGMuY2FsZW5kYXIub3BlbnRpbWVzdGFtcHMub3Jn//AQPDZsJgN1TnJXoUzlsgo93wjwIIfBc7LUqkCbC1BLZRZ+6LXztK50UdH5xe7fn40bupkrCPEEZdtm0/AI0CADXN5ZIncAg9/jDS75DI4uLWh0dHBzOi8vYWxpY2UuYnRjLmNhbGVuZGFyLm9wZW50aW1lc3RhbXBzLm9yZ/AQcELcSrE04cuGKlZQf2LeVwjwILUDSf9vK2GaefKTpn/LV2oUsQaA5WbqaP3C+1ZxQfRNCPEEZdtm0/AIbCtb+yRXFqUAg9/jDS75DI4pKGh0dHBzOi8vZmlubmV5LmNhbGVuZGFyLmV0ZXJuaXR5d2FsbC5jb20=\",\"sig\":\"f6854c0228c15c08aeb70bbabe9ed87bbb7289fab31b13cabac15138bb71179553e06080b83f4a813fbdaf614f63293beea3fc73fe865da6551193fa4d38de04\"}"
val otsEvent2Digest = "a8634f5368e17789e8fb3836cad0e4b9fe4f2288afafc28723c04bebc68a18d6"
@Before
fun setup() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
}
@Test
fun verifyNostrEvent() {
val ots = Event.fromJson(otsEvent) as OtsEvent
println(ots.info())
assertEquals(1707688818L, ots.verify())
}
@Test
fun verifyNostrEvent2() {
val ots = Event.fromJson(otsEvent2) as OtsEvent
println(ots.info())
assertEquals(1706322179L, ots.verify())
}
@Test
fun verifyNostrPendingEvent() {
val ots = Event.fromJson(otsPendingEvent) as OtsEvent
println(ots.info())
assertEquals(null, ots.verify())
}
@Test
fun createOTSEventAndVerify() {
val signer = NostrSignerInternal(KeyPair())
var ots: OtsEvent? = null
val countDownLatch = CountDownLatch(1)
val otsFile = OtsEvent.stamp(otsEvent2Digest)
OtsEvent.create(otsEvent2Digest, otsFile, signer) {
ots = it
countDownLatch.countDown()
}
Assert.assertTrue(countDownLatch.await(1, TimeUnit.SECONDS))
println(ots!!.toJson())
println(ots!!.info())
// Should not be valid because we need to wait for confirmations
assertEquals(null, ots!!.verify())
}
}

View File

@@ -29,7 +29,11 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil.ImageLoader
import coil.disk.DiskCache
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
import com.vitorpamplona.amethyst.service.playback.VideoCache
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.ots.OpenTimestamps
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -53,7 +57,7 @@ class Amethyst : Application() {
newCache
}
private val imageCache: DiskCache by lazy {
val coilCache: DiskCache by lazy {
DiskCache.Builder()
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
.maxSizePercent(0.2)
@@ -65,6 +69,8 @@ class Amethyst : Application() {
super.onCreate()
instance = this
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder().detectAll().penaltyLog().build(),
@@ -85,7 +91,7 @@ class Amethyst : Application() {
}
fun imageLoaderBuilder(): ImageLoader.Builder {
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
return ImageLoader.Builder(applicationContext).diskCache { coilCache }
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences {

View File

@@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
@@ -44,6 +43,8 @@ import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
@@ -112,6 +113,8 @@ private object PrefKeys {
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
const val SIGNER_PACKAGE_NAME = "signer_package_name"
const val HAS_DONATED_IN_VERSION = "has_donated_in_version"
const val PENDING_ATTESTATIONS = "pending_attestations"
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
const val SHARED_SETTINGS = "shared_settings"
@@ -331,12 +334,18 @@ object LocalPreferences {
PrefKeys.LAST_READ_PER_ROUTE,
Event.mapper.writeValueAsString(account.lastReadPerRoute),
)
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
putString(
PrefKeys.PENDING_ATTESTATIONS,
Event.mapper.writeValueAsString(account.pendingAttestations),
)
}
.apply()
}
@@ -521,7 +530,7 @@ object LocalPreferences {
val zapPaymentRequestServer =
try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
Event.mapper.readValue<Nip47URI?>(it)
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
@@ -554,6 +563,26 @@ object LocalPreferences {
null
}
val pendingAttestations =
try {
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
println("Decoding Attestation List: " + it)
if (it != null) {
Event.mapper.readValue<Map<HexKey, String>>(it)
} else {
null
}
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
"LocalPreferences",
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
e,
)
null
}
val languagePreferences =
try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
@@ -617,6 +646,8 @@ object LocalPreferences {
NostrSignerInternal(keyPair)
}
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val account =
Account(
keyPair = keyPair,
@@ -644,6 +675,8 @@ object LocalPreferences {
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
hasDonatedInVersion = hasDonatedInVersion,
pendingAttestations = pendingAttestations ?: emptyMap(),
)
// Loads from DB

View File

@@ -31,6 +31,7 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
@@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.BookmarkListEvent
@@ -65,6 +67,7 @@ import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GeneralListEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -74,6 +77,7 @@ import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NIP24Factory
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.Price
@@ -86,6 +90,7 @@ import com.vitorpamplona.quartz.events.Response
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.signers.NostrSigner
@@ -96,6 +101,7 @@ import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -170,7 +176,7 @@ class Account(
var defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
var zapPaymentRequest: Nip47URI? = null,
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP24WarningDialog: Boolean = false,
@@ -181,10 +187,10 @@ class Account(
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var hasDonatedInVersion: Set<String> = setOf<String>(),
var pendingAttestations: Map<HexKey, String> = mapOf<HexKey, String>(),
val scope: CoroutineScope = Amethyst.instance.applicationIOScope,
) {
// Uses a single scope for the entire application.
val scope = Amethyst.instance.applicationIOScope
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
data class PaymentRequest(
@@ -727,7 +733,7 @@ class Account(
zapResponseEvent.response(signer, onReady)
}
fun calculateIfNoteWasZappedByAccount(
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
onWasZapped: () -> Unit,
) {
@@ -891,6 +897,38 @@ class Account(
}
}
suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
pendingAttestations.toMap().forEach { pair ->
val newAttestation = OtsEvent.upgrade(pair.value, pair.key)
if (pair.value != newAttestation) {
OtsEvent.create(pair.key, newAttestation, signer) {
LocalCache.justConsume(it, null)
Client.send(it)
pendingAttestations = pendingAttestations - pair.key
}
}
}
}
fun hasPendingAttestations(note: Note): Boolean {
val id = note.event?.id() ?: note.idHex
return pendingAttestations.get(id) != null
}
fun timestamp(note: Note) {
if (!isWriteable()) return
val id = note.event?.id() ?: note.idHex
pendingAttestations = pendingAttestations + Pair(id, OtsEvent.stamp(id))
saveable.invalidateData()
}
fun follow(user: User) {
if (!isWriteable()) return
@@ -1166,7 +1204,7 @@ class Account(
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
return LocalCache.getNoteIfExists(signedEvent.id)
}
fun consumeNip95(
@@ -1176,7 +1214,7 @@ class Account(
LocalCache.consume(data, null)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
return LocalCache.getNoteIfExists(signedEvent.id)
}
fun sendNip95(
@@ -1196,7 +1234,7 @@ class Account(
Client.send(signedEvent, relayList = relayList)
LocalCache.consume(signedEvent, null)
LocalCache.notes[signedEvent.id]?.let { onReady(it) }
LocalCache.getNoteIfExists(signedEvent.id)?.let { onReady(it) }
}
fun createHeader(
@@ -1312,6 +1350,63 @@ class Account(
}
}
fun sendGitReply(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
repository: ATag?,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val addresses = listOfNotNull(repository) + (replyTo?.mapNotNull { it.address() } ?: emptyList())
GitReplyEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
extraTags = null,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
replyingTo = replyingTo,
root = root,
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
fun sendPost(
message: String,
replyTo: List<Note>?,
@@ -1323,6 +1418,7 @@ class Account(
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
@@ -1347,6 +1443,7 @@ class Account(
directMentions = directMentions,
geohash = geohash,
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
) {
Client.send(it, relayList = relayList)
@@ -1367,6 +1464,29 @@ class Account(
}
}
fun sendEdit(
message: String,
originalNote: Note,
notify: HexKey?,
summary: String? = null,
relayList: List<Relay>? = null,
) {
if (!isWriteable()) return
val idHex = originalNote.event?.id() ?: return
TextNoteModificationEvent.create(
content = message,
eventId = idHex,
notify = notify,
summary = summary,
signer = signer,
) {
LocalCache.justConsume(it, null)
Client.send(it, relayList = relayList)
}
}
fun sendPoll(
message: String,
replyTo: List<Note>?,
@@ -2037,7 +2157,7 @@ class Account(
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Nip47URI?) {
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
zapPaymentRequest = newServer
live.invalidateData()
saveable.invalidateData()
@@ -2354,6 +2474,16 @@ class Account(
return lastReadPerRoute[route] ?: 0
}
fun hasDonatedInThisVersion(): Boolean {
return hasDonatedInVersion.contains(BuildConfig.VERSION_NAME)
}
fun markDonatedInThisVersion() {
hasDonatedInVersion = hasDonatedInVersion + BuildConfig.VERSION_NAME
saveable.invalidateData()
live.invalidateData()
}
suspend fun registerObservers() =
withContext(Dispatchers.Main) {
// saves contact list for the next time.

View File

@@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.Dispatchers
@@ -58,7 +58,7 @@ class AntiSpamFilter {
// really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still
// blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
if (event.content.length < 180 && Nip19Bech32.nip19regex.matcher(event.content).find()) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -71,7 +71,7 @@ class AntiSpamFilter {
) {
Log.w(
"Potential SPAM Message for sharing",
"${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}",
"${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, null)}",
)
Log.w(
"Potential SPAM Message",

View File

@@ -21,18 +21,17 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.events.AppDefinitionEvent
@@ -65,12 +64,17 @@ import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
@@ -82,6 +86,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.MuteListEvent
import com.vitorpamplona.quartz.events.NNSEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
@@ -94,8 +99,11 @@ import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
@@ -112,18 +120,31 @@ import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.measureTimedValue
object LocalCache {
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>(5000)
val notes = ConcurrentHashMap<HexKey, Note>(5000)
private val users = ConcurrentHashMap<HexKey, User>(5000)
private val notes = ConcurrentHashMap<HexKey, Note>(5000)
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
var noteListCache: List<Note> = emptyList()
var userListCache: List<User> = emptyList()
fun updateListCache() {
val (value, elapsed) =
measureTimedValue {
noteListCache = ArrayList(notes.values)
userListCache = ArrayList(users.values)
}
Log.d("LocalCache", "UpdateListCache $elapsed")
}
fun checkGetOrCreateUser(key: String): User? {
// checkNotInMainThread()
@@ -360,6 +381,94 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: GitPatchEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: GitIssueEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: GitReplyEvent,
relay: Relay? = null,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
val repository = event.repository()?.toTag()
val replyTo =
event
.tagsWithoutCitations()
.filter { it != repository }
.mapNotNull { checkGetOrCreateNote(it) }
// println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach { it.addReply(note) }
refreshObservers(note)
}
fun consume(
event: LongTextNoteEvent,
relay: Relay?,
@@ -395,6 +504,41 @@ object LocalCache {
}
}
fun consume(
event: WikiNoteEvent,
relay: Relay?,
) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (antiSpam.isSpam(event, relay)) {
relay?.let { it.spamCounter++ }
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
refreshObservers(note)
}
}
fun consume(
event: PollNoteEvent,
relay: Relay? = null,
@@ -472,6 +616,13 @@ object LocalCache {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: GitRepositoryEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun consume(
event: ChannelListEvent,
relay: Relay?,
@@ -588,6 +739,30 @@ object LocalCache {
}
}
fun consume(
event: OtsEvent,
relay: Relay?,
) {
val version = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (version.event?.id() == event.id()) return
// makes sure the OTS has a valid certificate
if (event.cacheVerify() == null) return // no valid OTS
if (version.event == null) {
version.loadEvent(event, author, emptyList())
if (version.liveSet != null) {
updateListCache()
}
version.liveSet?.innerOts?.invalidateData()
}
refreshObservers(version)
}
fun consume(
event: BadgeDefinitionEvent,
relay: Relay?,
@@ -759,7 +934,7 @@ object LocalCache {
event
.deleteEvents()
.mapNotNull { notes[it] }
.mapNotNull { getNoteIfExists(it) }
.forEach { deleteNote ->
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) {
@@ -1200,6 +1375,57 @@ object LocalCache {
refreshObservers(note)
}
fun consume(
event: FhirResourceEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(
event: TextNoteModificationEvent,
relay: Relay?,
) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
event.editedNote()?.let {
checkGetOrCreateNote(it)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
if (editedNote.liveSet != null) {
updateListCache()
}
editedNote.liveSet?.innerModifications?.invalidateData()
}
}
refreshObservers(note)
}
fun consume(
event: HighlightEvent,
relay: Relay?,
@@ -1396,11 +1622,14 @@ object LocalCache {
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
return listOfNotNull(users[key])
if (key != null) {
val user = getUserIfExists(key)
if (user != null) {
return listOfNotNull(user)
}
}
return users.values.filter {
return userListCache.filter {
(it.anyNameStartsWith(username)) ||
it.pubkeyHex.startsWith(username, true) ||
it.pubkeyNpub().startsWith(username, true)
@@ -1410,19 +1639,16 @@ object LocalCache {
fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread()
val key =
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
val key = decodeEventIdAsHexOrNull(text)
if (key != null && (notes[key] ?: addressables[key]) != null) {
return listOfNotNull(notes[key] ?: addressables[key])
if (key != null) {
val note = getNoteIfExists(key)
if (note != null) {
return listOfNotNull(note)
}
}
return notes.values.filter {
return noteListCache.filter {
(
it.event !is GenericRepostEvent &&
it.event !is RepostEvent &&
@@ -1460,14 +1686,7 @@ object LocalCache {
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
val key =
try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
val key = decodeEventIdAsHexOrNull(text)
if (key != null && channels[key] != null) {
return listOfNotNull(channels[key])
}
@@ -1498,12 +1717,61 @@ object LocalCache {
.toImmutableList()
}
suspend fun findEarliestOtsForNote(note: Note): Long? {
checkNotInMainThread()
var minTime: Long? = null
val time = TimeUtils.now()
noteListCache.forEach { item ->
val noteEvent = item.event
if ((noteEvent is OtsEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time))) {
noteEvent.verifiedTime?.let { stampedTime ->
if (minTime == null || stampedTime < (minTime ?: Long.MAX_VALUE)) {
minTime = stampedTime
}
}
}
}
return minTime
}
val modificationCache = LruCache<HexKey, List<Note>>(20)
fun cachedModificationEventsForNote(note: Note): List<Note>? {
return modificationCache[note.idHex]
}
suspend fun findLatestModificationForNote(note: Note): List<Note> {
checkNotInMainThread()
val originalAuthor = note.author?.pubkeyHex ?: return emptyList()
modificationCache[note.idHex]?.let {
return it
}
val time = TimeUtils.now()
val newNotes =
noteListCache.filter { item ->
val noteEvent = item.event
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
modificationCache.put(note.idHex, newNotes)
return newNotes
}
fun cleanObservers() {
notes.forEach { it.value.clearLive() }
noteListCache.forEach { it.clearLive() }
addressables.forEach { it.value.clearLive() }
users.forEach { it.value.clearLive() }
userListCache.forEach { it.clearLive() }
}
fun pruneOldAndHiddenMessages(account: Account) {
@@ -1529,8 +1797,8 @@ object LocalCache {
}
}
users.forEach { userPair ->
userPair.value.privateChatrooms.values.map {
userListCache.forEach { userPair ->
userPair.privateChatrooms.values.map {
val toBeRemoved = it.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
@@ -1545,7 +1813,7 @@ object LocalCache {
if (toBeRemoved.size > 1) {
println(
"PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
"PRUNE: ${toBeRemoved.size} private messages with ${userPair.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
)
}
}
@@ -1554,9 +1822,9 @@ object LocalCache {
fun prunePastVersionsOfReplaceables() {
val toBeRemoved =
notes
noteListCache
.filter {
val noteEvent = it.value.event
val noteEvent = it.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt() <
(addressables[noteEvent.address().toTag()]?.event?.createdAt() ?: 0)
@@ -1564,7 +1832,6 @@ object LocalCache {
false
}
}
.values
val childrenToBeRemoved = mutableListOf<Note>()
@@ -1589,24 +1856,23 @@ object LocalCache {
checkNotInMainThread()
val toBeRemoved =
notes
noteListCache
.filter {
(
(it.value.event is TextNoteEvent && !it.value.isNewThread()) ||
it.value.event is ReactionEvent ||
it.value.event is LnZapEvent ||
it.value.event is LnZapRequestEvent ||
it.value.event is ReportEvent ||
it.value.event is GenericRepostEvent
(it.event is TextNoteEvent && !it.isNewThread()) ||
it.event is ReactionEvent ||
it.event is LnZapEvent ||
it.event is LnZapRequestEvent ||
it.event is ReportEvent ||
it.event is GenericRepostEvent
) &&
it.value.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
it.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author?.pubkeyHex !in
it.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
it.liveSet?.isInUse() != true && // don't delete if observing.
it.author?.pubkeyHex !in
accounts && // don't delete if it is the logged in account
it.value.event?.isTaggedUsers(accounts) !=
it.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user
}
.values
val childrenToBeRemoved = mutableListOf<Note>()
@@ -1672,7 +1938,8 @@ object LocalCache {
fun pruneExpiredEvents() {
checkNotInMainThread()
val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values
val now = TimeUtils.now()
val toBeRemoved = noteListCache.filter { it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>()
@@ -1698,7 +1965,7 @@ object LocalCache {
?.hiddenUsers
?.map { userHex ->
(
notes.values.filter { it.event?.pubKey() == userHex } +
noteListCache.filter { it.event?.pubKey() == userHex } +
addressables.values.filter { it.event?.pubKey() == userHex }
)
.toSet()
@@ -1720,7 +1987,7 @@ object LocalCache {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
userListCache.forEach {
if (
it.pubkeyHex !in loggedIn &&
(it.liveSet == null || it.liveSet?.isInUse() == false) &&
@@ -1806,11 +2073,16 @@ object LocalCache {
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)
is FhirResourceEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileServersEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay)
is GitIssueEvent -> consume(event, relay)
is GitReplyEvent -> consume(event, relay)
is GitPatchEvent -> consume(event, relay)
is GitRepositoryEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
@@ -1828,6 +2100,7 @@ object LocalCache {
is MetadataEvent -> consume(event)
is MuteListEvent -> consume(event, relay)
is NNSEvent -> comsume(event, relay)
is OtsEvent -> consume(event, relay)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event, relay)
is PeopleListEvent -> consume(event, relay)
@@ -1846,8 +2119,10 @@ object LocalCache {
}
is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay)
is TextNoteModificationEvent -> consume(event, relay)
is VideoHorizontalEvent -> consume(event, relay)
is VideoVerticalEvent -> consume(event, relay)
is WikiNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
@@ -1868,6 +2143,10 @@ class LocalCacheLiveData {
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes -> _newEventBundles.emit(bundledNewNotes) }
bundler.invalidateList(newNote) {
bundledNewNotes ->
LocalCache.updateListCache()
_newEventBundles.emit(bundledNewNotes)
}
}
}

View File

@@ -39,7 +39,7 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.RepostEvent
@@ -66,10 +67,13 @@ import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
@@ -138,17 +142,17 @@ open class Note(val idHex: String) {
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
Nip19Bech32.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()?.url,
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
Nip19Bech32.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
}
@@ -521,7 +525,7 @@ open class Note(val idHex: String) {
}
}
private fun recursiveIsZappedByCalculation(
private suspend fun isZappedByCalculation(
option: Int?,
user: User,
account: Account,
@@ -532,48 +536,65 @@ open class Note(val idHex: String) {
return
}
val next = remainingZapEvents.first()
remainingZapEvents.forEach { next ->
val zapRequest = next.first.event as LnZapRequestEvent
val zapEvent = next.second?.event as? LnZapEvent
if (next.first.author?.pubkeyHex == user.pubkeyHex) {
onWasZappedByAuthor()
} else {
account.decryptZapContentAuthor(next.first) {
if (
it.pubKey == user.pubkeyHex &&
(option == null || option == (it as? LnZapEvent)?.zappedPollOption())
) {
if (!zapRequest.isPrivateZap()) {
// public events
if (zapRequest.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
} else {
// private events
// if has already decrypted
val privateZap = zapRequest.cachedPrivateZap()
if (privateZap != null) {
if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
} else {
recursiveIsZappedByCalculation(
option,
user,
account,
remainingZapEvents.minus(next),
onWasZappedByAuthor,
)
if (account.isWriteable()) {
val result =
withTimeoutOrNull(1000) {
suspendCancellableCoroutine { continuation ->
zapRequest.decryptPrivateZap(account.signer) {
continuation.resume(it)
}
}
}
if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
}
}
}
}
}
fun isZappedBy(
suspend fun isZappedBy(
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
fun isZappedBy(
suspend fun isZappedBy(
option: Int?,
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
isZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
@@ -926,6 +947,8 @@ class NoteLiveSet(u: Note) {
val innerReports = NoteBundledRefresherLiveData(u)
val innerRelays = NoteBundledRefresherLiveData(u)
val innerZaps = NoteBundledRefresherLiveData(u)
val innerOts = NoteBundledRefresherLiveData(u)
val innerModifications = NoteBundledRefresherLiveData(u)
val metadata = innerMetadata.map { it }
val reactions = innerReactions.map { it }
@@ -979,7 +1002,9 @@ class NoteLiveSet(u: Note) {
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers()
boostCount.hasObservers() ||
innerOts.hasObservers() ||
innerModifications.hasObservers()
}
fun destroy() {
@@ -990,6 +1015,8 @@ class NoteLiveSet(u: Note) {
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
innerOts.destroy()
innerModifications.destroy()
}
}

View File

@@ -139,10 +139,10 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
LocalCache.getUserIfExists(it)?.liveSet?.innerFollowers?.invalidateData()
}
liveSet?.innerRelays?.invalidateData()
@@ -363,7 +363,7 @@ class User(val pubkeyHex: String) {
}
suspend fun transientFollowerCount(): Int {
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
@@ -387,7 +387,7 @@ class User(val pubkeyHex: String) {
}
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.count { it.value.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
return LocalCache.userListCache.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {

View File

@@ -20,48 +20,13 @@
*/
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.encoders.Nip54
import com.vitorpamplona.quartz.encoders.Nip92
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import java.util.regex.Pattern
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>,
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
val richTextCache = LruCache<String, RichTextViewerState>(50)
fun parseText(
content: String,
@@ -76,298 +41,3 @@ object CachedRichTextParser {
}
}
}
// Group 1 = url, group 4 additional chars
// val noProtocolUrlValidator =
// Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator =
try {
Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)",
)
} catch (e: Exception) {
Pattern.compile(
"(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)",
)
}
val HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
class RichTextParser() {
fun parseMediaUrl(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
): ZoomableUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlImage(
url = fullUrl,
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54().parse(fullUrl)
val tags = Nip92().parse(fullUrl, eventTags.lists)
ZoomableUrlVideo(
url = fullUrl,
description = frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
)
} else {
null
}
}
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet =
urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments,
)
}
private fun findTextSegments(
content: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
val lines = content.split('\n')
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
lines.forEach { paragraph ->
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments.add(wordSegment)
}
val newSegments =
if (isDirty) {
ParagraphState(segments.toPersistentList(), isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments.add(newSegments)
}
return paragraphSegments.toImmutableList()
}
fun isNumber(word: String): Boolean {
return numberPattern.matcher(word).matches()
}
fun isDate(word: String): Boolean {
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
private fun wordIdentifier(
word: String,
images: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4).ifEmpty { null } // additional chars
val pattern =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
if (pattern.find(word) != null) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
val hashtagMatcher = hashTagsPattern.matcher(word)
try {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2).ifEmpty { null })
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@Immutable open class Segment(val segmentText: String)
@Immutable class ImageSegment(segment: String) : Segment(segment)
@Immutable class LinkSegment(segment: String) : Segment(segment)
@Immutable class EmojiSegment(segment: String) : Segment(segment)
@Immutable class InvoiceSegment(segment: String) : Segment(segment)
@Immutable class WithdrawSegment(segment: String) : Segment(segment)
@Immutable class CashuSegment(segment: String) : Segment(segment)
@Immutable class EmailSegment(segment: String) : Segment(segment)
@Immutable class PhoneSegment(segment: String) : Segment(segment)
@Immutable class BechSegment(segment: String) : Segment(segment)
@Immutable
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) :
Segment(segment)
@Immutable
class HashIndexUserSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras)
@Immutable
class HashIndexEventSegment(segment: String, hex: String, extras: String?) :
HashIndexSegment(segment, hex, extras)
@Immutable
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
@Immutable
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) :
Segment(segment)
@Immutable class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -42,6 +43,24 @@ data class CashuToken(
val proofs: JsonNode,
)
object CachedCashuProcessor {
val cashuCache = LruCache<String, GenericLoadable<CashuToken>>(20)
fun cached(token: String): GenericLoadable<CashuToken> {
return cashuCache[token] ?: GenericLoadable.Loading()
}
fun parse(token: String): GenericLoadable<CashuToken> {
if (cashuCache[token] !is GenericLoadable.Loaded) {
val newCachuData = CashuProcessor().parse(token)
cashuCache.put(token, newCachuData)
}
return cashuCache[token]
}
}
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
checkNotInMainThread()

View File

@@ -27,6 +27,7 @@ import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.HandlerThread
import android.util.LruCache
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.CancellationException
@@ -92,7 +93,33 @@ class LocationUtil(context: Context) {
}
}
class ReverseGeoLocationUtil {
object CachedGeoLocations {
val locationNames = LruCache<String, String>(20)
fun cached(geoHashStr: String): String? {
return locationNames[geoHashStr]
}
suspend fun geoLocate(
geoHashStr: String,
location: Location,
context: Context,
): String? {
locationNames[geoHashStr]?.let {
return it
}
val name = ReverseGeoLocationUtil().execute(location, context)?.ifBlank { null }
if (name != null) {
locationNames.put(geoHashStr, name)
}
return name
}
}
private class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context,

View File

@@ -22,7 +22,8 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.Callback
@@ -31,29 +32,64 @@ import okhttp3.Response
import java.io.IOException
object Nip11CachedRetriever {
val relayInformationDocumentCache = LruCache<String, RelayInformation>(100)
open class RetrieveResult(val time: Long)
class RetrieveResultError(val error: Nip11Retriever.ErrorCode, val msg: String? = null) : RetrieveResult(TimeUtils.now())
class RetrieveResultSuccess(val data: Nip11RelayInformation) : RetrieveResult(TimeUtils.now())
val relayInformationDocumentCache = LruCache<String, RetrieveResult?>(100)
val retriever = Nip11Retriever()
fun getFromCache(dirtyUrl: String): Nip11RelayInformation? {
val result = relayInformationDocumentCache.get(retriever.cleanUrl(dirtyUrl)) ?: return null
if (result is RetrieveResultSuccess) return result.data
return null
}
suspend fun loadRelayInfo(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
val url = retriever.cleanUrl(dirtyUrl)
val doc = relayInformationDocumentCache.get(url)
if (doc != null) {
onInfo(doc)
if (doc is RetrieveResultSuccess) {
onInfo(doc.data)
} else if (doc is RetrieveResultError) {
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
onError(dirtyUrl, doc.error, null)
} else {
Nip11Retriever()
.loadRelayInfo(
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
} else {
Nip11Retriever()
.loadRelayInfo(
url,
dirtyUrl,
url = url,
dirtyUrl = dirtyUrl,
onInfo = {
relayInformationDocumentCache.put(url, it)
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
onInfo(it)
},
onError,
onError = { dirtyUrl, code, errorMsg ->
relayInformationDocumentCache.put(url, RetrieveResultError(code, errorMsg))
onError(url, code, errorMsg)
},
)
}
}
@@ -78,9 +114,10 @@ class Nip11Retriever {
suspend fun loadRelayInfo(
url: String,
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, ErrorCode, String?) -> Unit,
) {
checkNotInMainThread()
try {
val request: Request =
Request.Builder().header("Accept", "application/nostr+json").url(url).build()
@@ -98,7 +135,7 @@ class Nip11Retriever {
val body = it.body.string()
try {
if (it.isSuccessful) {
onInfo(RelayInformation.fromJson(body))
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(dirtyUrl, ErrorCode.FAIL_WITH_HTTP_STATUS, it.code.toString())
}

View File

@@ -24,9 +24,12 @@ import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexValidator
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
@@ -66,83 +69,118 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
null
}
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
when (val parsed = Nip19Bech32.uriToRoute(mySearchString)?.entity) {
is Nip19Bech32.NSec -> KeyPair(privKey = parsed.hex.bechToBytes()).pubKey.toHexKey()
is Nip19Bech32.NPub -> parsed.hex
is Nip19Bech32.NProfile -> parsed.hex
is Nip19Bech32.Note -> parsed.hex
is Nip19Bech32.NEvent -> parsed.hex
is Nip19Bech32.NEmbed -> parsed.event.id
is Nip19Bech32.NRelay -> null
is Nip19Bech32.NAddress -> parsed.atag
else -> isAStraightHex
}
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
// downloads all the reactions to a given event.
return listOfNotNull(
val directReferenceFilters =
hexToWatch?.let {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
ids = listOfNotNull(hexToWatch),
if (it.contains(":")) {
// naddr
listOfNotNull(
ATag.parse(it, null)?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND, aTag.kind),
authors = listOfNotNull(aTag.pubKeyHex),
// just to be sure
limit = 5,
),
)
},
)
} else {
// event ids
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
ids = listOfNotNull(hexToWatch),
),
),
)
},
hexToWatch?.let {
// authors
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
// just to be sure
limit = 5,
),
),
)
}
} ?: emptyList()
// downloads all the reactions to a given event.
return directReferenceFilters +
listOfNotNull(
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
authors = listOfNotNull(hexToWatch),
search = mySearchString,
limit = 100,
),
)
},
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds = listOf(MetadataEvent.KIND),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
LongTextNoteEvent.KIND,
BadgeDefinitionEvent.KIND,
PeopleListEvent.KIND,
BookmarkListEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
PinListEvent.KIND,
PollNoteEvent.KIND,
ChannelCreateEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter =
JsonFilter(
kinds =
listOf(
ChannelMetadataEvent.KIND,
ClassifiedsEvent.KIND,
CommunityDefinitionEvent.KIND,
EmojiPackEvent.KIND,
HighlightEvent.KIND,
LiveActivitiesEvent.KIND,
PollNoteEvent.KIND,
NNSEvent.KIND,
),
search = mySearchString,
limit = 100,
),
),
)
}
val searchChannel = requestNewChannel()

View File

@@ -29,13 +29,16 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.OtsEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
@@ -120,27 +123,53 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
return groupByEOSEPresence(eventsToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
OtsEvent.KIND,
TextNoteModificationEvent.KIND,
GitReplyEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}.flatten()
}
private fun createQuotesFilter(): List<TypedFilter>? {
if (eventsToWatch.isEmpty()) {
return null
}
return groupByEOSEPresence(eventsToWatch).map {
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
tags = mapOf("q" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
)
}.flatten()
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
@@ -205,9 +234,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
val quotes = createQuotesFilter()
singleEventChannel.typedFilters =
listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
listOfNotNull(missing, addresses, reactions, addressReactions, quotes).flatten().ifEmpty { null }
}
fun add(eventId: Note) {

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.lnurl
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import kotlinx.coroutines.CancellationException
import java.text.NumberFormat
@Stable
data class InvoiceAmount(val invoice: String, val amount: String?)
object CachedLnInvoiceParser {
val lnInvoicesCache = LruCache<String, InvoiceAmount>(20)
fun cached(lnurl: String): InvoiceAmount? {
return lnInvoicesCache[lnurl]
}
fun parse(lnbcWord: String): InvoiceAmount? {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount =
try {
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
null
}
val lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
lnInvoicesCache.put(lnbcWord, lnInvoice)
return lnInvoice
}
return null
}
}

View File

@@ -112,7 +112,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
val chatNote = LocalCache.notes[event.id] ?: return
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet()
@@ -145,7 +145,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event: PrivateDmEvent,
acc: Account,
) {
val note = LocalCache.notes[event.id] ?: return
val note = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
@@ -184,7 +184,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
event: LnZapEvent,
acc: Account,
) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
// old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return

View File

@@ -0,0 +1,103 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.ots.BitcoinExplorer
import com.vitorpamplona.quartz.ots.BlockHeader
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import okhttp3.Request
class OkHttpBlockstreamExplorer : BitcoinExplorer {
/**
* Retrieve the block information from the block hash.
*
* @param hash Hash of the block.
* @return the blockheader of the hash
* @throws Exception desc
*/
override fun block(hash: String): BlockHeader {
val client = HttpClientManager.getHttpClient()
val url = "$BLOCKSTREAM_API_URL/block/$hash"
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val jsonObject = jacksonObjectMapper().readTree(it.body.string())
val blockHeader = BlockHeader()
blockHeader.merkleroot = jsonObject["merkle_root"].asText()
blockHeader.setTime(jsonObject["timestamp"].asInt().toString())
blockHeader.blockHash = hash
Log.d("OkHttpBlockstreamExplorer", "$BLOCKSTREAM_API_URL/block/$hash")
return blockHeader
} else {
throw UrlException("Couldn't open $url: " + it.message + " " + it.code)
}
}
}
/**
* Retrieve the block hash from the block height.
*
* @param height Height of the block.
* @return the hash of the block at height height
* @throws Exception desc
*/
@Throws(Exception::class)
override fun blockHash(height: Int): String {
val client = HttpClientManager.getHttpClient()
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
val request =
Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val blockHash = it.body.string()
Log.d("OkHttpBlockstreamExplorer", "$url $blockHash")
return blockHash
} else {
throw UrlException(it.message)
}
}
}
companion object {
private const val BLOCKSTREAM_API_URL = "https://blockstream.info/api"
}
}

View File

@@ -0,0 +1,133 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.ots.Timestamp
import com.vitorpamplona.quartz.ots.exceptions.CommitmentNotFoundException
import com.vitorpamplona.quartz.ots.exceptions.DeserializationException
import com.vitorpamplona.quartz.ots.exceptions.ExceededSizeException
import com.vitorpamplona.quartz.ots.exceptions.UrlException
import com.vitorpamplona.quartz.ots.http.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Class representing remote calendar server interface.
*/
class OkHttpCalendar(val url: String) : ICalendar {
/**
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
*
* @param digest The digest hash to send.
* @return the Timestamp received from the calendar.
* @throws ExceededSizeException if response is too big.
* @throws UrlException if url is not reachable.
* @throws DeserializationException if the data is corrupt
*/
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
override fun submit(digest: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
return Timestamp.deserialize(ctx, digest)
} else {
throw UrlException("Failed to open $url")
}
}
} catch (e: ExceededSizeException) {
throw e
} catch (e: DeserializationException) {
throw e
} catch (e: Exception) {
throw UrlException(e.message)
}
}
/**
* Get a timestamp for a given commitment.
*
* @param commitment The digest hash to send.
* @return the Timestamp from the calendar server (with blockchain information if already written).
* @throws ExceededSizeException if response is too big.
* @throws UrlException if url is not reachable.
* @throws CommitmentNotFoundException if commit is not found.
* @throws DeserializationException if the data is corrupt
*/
@Throws(
DeserializationException::class,
ExceededSizeException::class,
CommitmentNotFoundException::class,
UrlException::class,
)
override fun getTimestamp(commitment: ByteArray): Timestamp {
try {
val client = HttpClientManager.getHttpClient()
val url = url + "/timestamp/" + Hex.encode(commitment)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.get()
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
return Timestamp.deserialize(ctx, commitment)
} else {
throw CommitmentNotFoundException("Calendar response a status code != 200: " + it.code)
}
}
} catch (e: DeserializationException) {
throw e
} catch (e: ExceededSizeException) {
throw e
} catch (e: CommitmentNotFoundException) {
throw e
} catch (e: Exception) {
throw UrlException(e.message)
}
}
}

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
import com.vitorpamplona.quartz.ots.StreamDeserializationContext
import com.vitorpamplona.quartz.ots.Timestamp
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Optional
import java.util.concurrent.BlockingQueue
/**
* For making async calls to a calendar server
*/
class OkHttpCalendarAsyncSubmit(private val url: String, private val digest: ByteArray) : ICalendarAsyncSubmit {
private var queue: BlockingQueue<Optional<Timestamp>>? = null
fun setQueue(queue: BlockingQueue<Optional<Timestamp>>?) {
this.queue = queue
}
@Throws(Exception::class)
override fun call(): Optional<Timestamp> {
val client = HttpClientManager.getHttpClient()
val url = "$url/digest"
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
val requestBody = digest.toRequestBody(mediaType)
val request =
okhttp3.Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/vnd.opentimestamps.v1")
.header("Content-Type", "application/x-www-form-urlencoded")
.url(url)
.post(requestBody)
.build()
client.newCall(request).execute().use {
if (it.isSuccessful) {
val ctx = StreamDeserializationContext(it.body.bytes())
val timestamp = Timestamp.deserialize(ctx, digest)
val of = Optional.of(timestamp)
queue!!.add(of)
return of
} else {
queue!!.add(Optional.empty())
return Optional.empty()
}
}
}
}

View File

@@ -18,8 +18,21 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
package com.vitorpamplona.amethyst.service.ots
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.ots.CalendarBuilder
import com.vitorpamplona.quartz.ots.ICalendar
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
class OkHttpCalendarBuilder : CalendarBuilder {
override fun newSyncCalendar(url: String): ICalendar {
return OkHttpCalendar(url)
}
override fun newAsyncCalendar(
url: String,
digest: ByteArray,
): ICalendarAsyncSubmit {
return OkHttpCalendarAsyncSubmit(url, digest)
}
}

View File

@@ -108,7 +108,7 @@ object Client : RelayPool.Listener {
checkNotInMainThread()
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
RelayPool.connectAndSendFiltersIfDisconnected()
}
fun send(
@@ -292,7 +292,7 @@ object Client : RelayPool.Listener {
open fun onRelayStateChange(
type: Relay.StateType,
relay: Relay,
channel: String?,
subscriptionId: String?,
) = Unit
/** When an relay saves or rejects a new event. */

View File

@@ -37,26 +37,26 @@ class JsonFilter(
val filter =
factory.objectNode().apply {
ids?.run {
put(
replace(
"ids",
factory.arrayNode(ids.size).apply { ids.forEach { add(it) } },
)
}
authors?.run {
put(
replace(
"authors",
factory.arrayNode(authors.size).apply { authors.forEach { add(it) } },
)
}
kinds?.run {
put(
replace(
"kinds",
factory.arrayNode(kinds.size).apply { kinds.forEach { add(it) } },
)
}
tags?.run {
entries.forEach { kv ->
put(
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)

View File

@@ -409,7 +409,7 @@ class Relay(
return buffer.toString()
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
fun connectAndSendFiltersIfDisconnected() {
checkNotInMainThread()
if (socket == null) {

View File

@@ -81,8 +81,8 @@ object RelayPool : Relay.Listener {
relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
fun connectAndSendFiltersIfDisconnected() {
relays.forEach { it.connectAndSendFiltersIfDisconnected() }
}
fun sendToSelectedRelays(

View File

@@ -47,7 +47,7 @@ data class Subscription(
return factory.objectNode().apply {
put("id", id)
typedFilters?.also { filters ->
put(
replace(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter -> add(filter.toJsonObject()) }

View File

@@ -36,8 +36,8 @@ class TypedFilter(
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("types", typesToJson(types))
put("filter", filterToJson(filter))
replace("types", typesToJson(types))
replace("filter", filterToJson(filter))
}
}
@@ -50,26 +50,26 @@ class TypedFilter(
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
filter.ids?.run {
put(
replace(
"ids",
factory.arrayNode(filter.ids.size).apply { filter.ids.forEach { add(it) } },
)
}
filter.authors?.run {
put(
replace(
"authors",
factory.arrayNode(filter.authors.size).apply { filter.authors.forEach { add(it) } },
)
}
filter.kinds?.run {
put(
replace(
"kinds",
factory.arrayNode(filter.kinds.size).apply { filter.kinds.forEach { add(it) } },
)
}
filter.tags?.run {
entries.forEach { kv ->
put(
replace(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply { kv.value.forEach { add(it) } },
)

View File

@@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
@@ -52,12 +53,12 @@ import com.vitorpamplona.amethyst.ui.components.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.note.Nip47WalletConnectParser
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
@@ -311,11 +312,12 @@ fun uriToRoute(uri: String?): String? {
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
val nip19 = Nip19Bech32.uriToRoute(uri)?.entity
when (nip19) {
is Nip19Bech32.NPub -> "User/${nip19.hex}"
is Nip19Bech32.NProfile -> "User/${nip19.hex}"
is Nip19Bech32.Note -> "Note/${nip19.hex}"
is Nip19Bech32.NEvent -> {
if (nip19.kind == PrivateDmEvent.KIND) {
nip19.author?.let { "RoomByAuthor/$it" }
} else if (
@@ -328,20 +330,27 @@ fun uriToRoute(uri: String?): String? {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS ->
is Nip19Bech32.NAddress -> {
if (nip19.kind == CommunityDefinitionEvent.KIND) {
"Community/${nip19.hex}"
"Community/${nip19.atag}"
} else if (nip19.kind == LiveActivitiesEvent.KIND) {
"Channel/${nip19.hex}"
"Channel/${nip19.atag}"
} else {
"Event/${nip19.hex}"
"Event/${nip19.atag}"
}
}
is Nip19Bech32.NEmbed -> {
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
LocalCache.verifyAndConsume(nip19.event, null)
}
"Event/${nip19.event.id}"
}
else -> null
}
}
?: try {
uri?.let {
Nip47WalletConnectParser.parse(it)
Nip47WalletConnect.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}

View File

@@ -0,0 +1,555 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.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.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.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditPostView(
onClose: () -> Unit,
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val postViewModel: EditPostViewModel = viewModel()
postViewModel.prepare(edit, versionLookingAt, accountViewModel)
val context = LocalContext.current
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(edit, versionLookingAt, accountViewModel)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
}
}
DisposableEffect(Unit) {
NostrSearchEventOrUserDataSource.start()
onDispose {
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
if (showRelaysDialog) {
RelaySelectionDialog(
preSelectedList = relayList,
onClose = { showRelaysDialog = false },
onPost = { relayList = it },
accountViewModel = accountViewModel,
nav = nav,
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = StdHorzSpacer)
Box {
IconButton(
modifier = Modifier.align(Alignment.Center),
onClick = { showRelaysDialog = true },
) {
Icon(
painter = painterResource(R.drawable.relays),
contentDescription = stringResource(id = R.string.relay_list_selector),
modifier = Modifier.height(25.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
}
}
PostButton(
onPost = {
postViewModel.sendPost(relayList = relayList)
scope.launch {
delay(100)
onClose()
}
},
isActive = postViewModel.canPost(),
)
}
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
CloseButton(
onPress = {
postViewModel.cancel()
scope.launch {
delay(100)
onClose()
}
},
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(
start = Size10dp,
top = pad.calculateTopPadding(),
end = Size10dp,
bottom = pad.calculateBottomPadding(),
)
.fillMaxSize(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(),
) {
Column(
modifier =
Modifier
.imePadding()
.weight(1f),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
postViewModel.editedFromNote?.let {
Row(Modifier.heightIn(max = 200.dp)) {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colorScheme.replyModifier,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
}
MessageField(postViewModel)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (RichTextParser.isValidURL(myUrlPreview)) {
if (RichTextParser.isImageUrl(myUrlPreview)) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
)
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
roundedCorner = true,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
ImageVideoDescription(
url,
accountViewModel.account.defaultFileServer,
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
if (!server.isNip95) {
accountViewModel.account.changeDefaultFileServer(server.server)
}
},
onCancel = { postViewModel.contentToAddUrl = null },
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
accountViewModel = accountViewModel,
)
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && postViewModel.wantsInvoice) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
accountViewModel.account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = { postViewModel.wantsInvoice = false },
onError = { title, message -> accountViewModel.toast(title, message) },
)
}
}
}
/*
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column {
Text(
text = stringResource(R.string.message_to_author),
fontSize = 18.sp,
fontWeight = FontWeight.W500,
)
Divider()
MyTextField(
value = postViewModel.subject,
onValueChange = { postViewModel.updateSubject(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.message_to_author_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
visualTransformation =
UrlUserTagTransformation(
MaterialTheme.colorScheme.primary,
),
colors =
OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
),
)
}
}*/
}
}
ShowUserSuggestionListForEdit(
postViewModel,
accountViewModel,
modifier = Modifier.heightIn(0.dp, 300.dp),
)
BottomRowActions(postViewModel)
}
}
}
}
}
}
@Composable
fun ShowUserSuggestionListForEdit(
editPostViewModel: EditPostViewModel,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier.heightIn(0.dp, 200.dp),
) {
val userSuggestions = editPostViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding =
PaddingValues(
top = 10.dp,
),
modifier = modifier,
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex },
) { _, item ->
UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) }
}
}
}
}
@Composable
private fun BottomRowActions(postViewModel: EditPostViewModel) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.fillMaxWidth()
.height(50.dp),
verticalAlignment = CenterVertically,
) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier,
) {
postViewModel.selectImage(it)
}
if (postViewModel.canAddInvoice) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
}
}
@Composable
private fun MessageField(postViewModel: EditPostViewModel) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(200)
focusRequester.requestFocus()
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.updateMessage(it) },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
modifier =
Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(8.dp),
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colorScheme.placeholderText,
)
},
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
onClick: () -> Unit,
) {
IconButton(
onClick = { onClick() },
) {
if (!isLnInvoiceActive) {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.add_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground,
)
} else {
Icon(
imageVector = Icons.Default.CurrencyBitcoin,
contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice),
modifier = Modifier.size(20.dp),
tint = BitcoinOrange,
)
}
}
}

View File

@@ -0,0 +1,390 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
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.FileHeader
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class EditPostViewModel() : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var editedFromNote: Note? = null
var subject by mutableStateOf(TextFieldValue(""))
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by
mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError =
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
open fun prepare(
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
this.editedFromNote = edit
}
open fun load(
edit: Note,
versionLookingAt: Note?,
accountViewModel: AccountViewModel,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
contentToAddUrl = null
message = TextFieldValue(versionLookingAt?.event?.content() ?: edit.event?.content() ?: "")
urlPreview = findUrlInMessage()
editedFromNote = edit
}
fun sendPost(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
if (accountViewModel == null) {
cancel()
return
}
nip95attachments.forEach {
account?.sendNip95(it.first, it.second, relayList)
}
val notify =
if (editedFromNote?.author?.pubkeyHex == account?.userProfile()?.pubkeyHex) {
null
} else {
// notifies if it is not the logged in user
editedFromNote?.author?.pubkeyHex
}
account?.sendEdit(
message = message.text,
originalNote = editedFromNote!!,
notify = notify,
summary = subject.text.ifBlank { null },
relayList = relayList,
)
cancel()
}
open fun updateSubject(it: TextFieldValue) {
subject = it
}
fun upload(
galleryUri: Uri,
alt: String?,
sensitiveContent: Boolean,
isPrivate: Boolean = false,
server: ServerOption,
context: Context,
) {
isUploadingImage = true
contentToAddUrl = null
val contentResolver = context.contentResolver
val contentType = contentResolver.getType(galleryUri)
viewModelScope.launch(Dispatchers.IO) {
MediaCompressor()
.compress(
galleryUri,
contentType,
context.applicationContext,
onReady = { fileUri, contentType, size ->
if (server.isNip95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
}
} else {
viewModelScope.launch(Dispatchers.IO) {
try {
val result =
Nip96Uploader(account)
.uploadImage(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = if (sensitiveContent) "" else null,
server = server.server,
contentResolver = contentResolver,
onProgress = {},
)
createNIP94Record(
uploadingResult = result,
localContentType = contentType,
alt = alt,
sensitiveContent = sensitiveContent,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"ImageUploader",
"Failed to upload ${e.message}",
e,
)
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload: ${e.message}")
}
}
}
}
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit(it) }
},
)
}
}
open fun cancel() {
message = TextFieldValue("")
subject = TextFieldValue("")
editedFromNote = null
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
wantsInvoice = false
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
}
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
if (it.selection.collapsed) {
val lastWord =
it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
userSuggestions =
LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed()
}
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
}
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) {
val lastWord =
message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
message =
TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length),
)
}
userSuggestionAnchor = null
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
}
fun canPost(): Boolean {
return message.text.isNotBlank() &&
!isUploadingImage &&
!wantsInvoice &&
contentToAddUrl == null
}
suspend fun createNIP94Record(
uploadingResult: Nip96Uploader.PartialEvent,
localContentType: String?,
alt: String?,
sensitiveContent: Boolean,
) {
// Images don't seem to be ready immediately after upload
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
val remoteMimeType =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null }
val originalHash =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null }
val dim =
uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null }
val magnet =
uploadingResult.tags
?.firstOrNull { it.size > 1 && it[0] == "magnet" }
?.get(1)
?.ifBlank { null }
if (imageUrl.isNullOrBlank()) {
Log.e("ImageDownload", "Couldn't download image from server")
cancel()
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
return
}
FileHeader.prepare(
fileUrl = imageUrl,
mimeType = remoteMimeType ?: localContentType,
dimPrecomputed = dim,
onReady = { header: FileHeader ->
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
isUploadingImage = false
nip94attachments = nip94attachments + event
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
},
)
}
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
alt: String?,
sensitiveContent: Boolean,
) {
if (bytes.size > 80000) {
viewModelScope.launch {
imageUploadingError.emit("Media is too big for NIP-95")
isUploadingImage = false
}
return
}
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
mimeType,
null,
onReady = {
account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 ->
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) }
isUploadingImage = false
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
}
},
onError = {
isUploadingImage = false
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
},
)
}
}
fun selectImage(uri: Uri) {
contentToAddUrl = uri
}
}

View File

@@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toNpub
import kotlinx.coroutines.CancellationException
@@ -68,17 +68,23 @@ class NewMessageTagger(
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(dao.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
when (val entity = results?.key?.entity) {
is Nip19Bech32.NPub -> addUserToMentions(dao.getOrCreateUser(entity.hex))
is Nip19Bech32.NProfile -> addUserToMentions(dao.getOrCreateUser(entity.hex))
is Nip19Bech32.Note -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex))
is Nip19Bech32.NEvent -> addNoteToReplyTos(dao.getOrCreateNote(entity.hex))
is Nip19Bech32.NEmbed -> addNoteToReplyTos(dao.getOrCreateNote(entity.event.id))
is Nip19Bech32.NAddress -> {
val note = dao.checkGetOrCreateAddressableNote(entity.atag)
if (note != null) {
addNoteToReplyTos(note)
}
}
is Nip19Bech32.NSec -> {}
is Nip19Bech32.NRelay -> {}
}
}
}
@@ -92,27 +98,45 @@ class NewMessageTagger(
.split(' ')
.map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = dao.getOrCreateUser(results.key.hex)
when (val entity = results?.key?.entity) {
is Nip19Bech32.NPub -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
}
is Nip19Bech32.NProfile -> {
getNostrAddress(dao.getOrCreateUser(entity.hex).pubkeyNpub(), results.restOfWord)
}
getNostrAddress(user.pubkeyNpub(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = dao.getOrCreateNote(results.key.hex)
is Nip19Bech32.Note -> {
getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord)
}
is Nip19Bech32.NEvent -> {
getNostrAddress(dao.getOrCreateNote(entity.hex).toNEvent(), results.restOfWord)
}
getNostrAddress(note.toNEvent(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = dao.getOrCreateNote(results.key.hex)
is Nip19Bech32.NAddress -> {
val note = dao.checkGetOrCreateAddressableNote(entity.atag)
if (note != null) {
getNostrAddress(note.idNote(), results.restOfWord)
} else {
word
}
}
getNostrAddress(note.toNEvent(), results.restOfWord)
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
getNostrAddress(note.idNote(), results.restOfWord)
} else {
is Nip19Bech32.NEmbed -> {
word
}
is Nip19Bech32.NSec -> {
word
}
is Nip19Bech32.NRelay -> {
word
}
else -> {
word
}
} else {
word
}
}
.joinToString(" ")
@@ -135,7 +159,7 @@ class NewMessageTagger(
}
}
@Immutable data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
@Immutable data class DirtyKeyInfo(val key: Nip19Bech32.ParseReturn, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
@@ -155,7 +179,7 @@ class NewMessageTagger(
val restOfWord = key.substring(63)
// Converts to npub
val pubkey =
Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
Nip19Bech32.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
@@ -166,7 +190,7 @@ class NewMessageTagger(
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
val pubkey = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
@@ -177,19 +201,19 @@ class NewMessageTagger(
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val noteId = Nip19.uriToRoute(keyB32) ?: return null
val noteId = Nip19Bech32.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(key) ?: return null
val pubkeyRelay = Nip19Bech32.uriToRoute(key) ?: return null
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(key) ?: return null
val noteRelayId = Nip19Bech32.uriToRoute(key) ?: return null
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(key) ?: return null
val address = Nip19Bech32.uriToRoute(key) ?: return null
return DirtyKeyInfo(
address,

View File

@@ -124,31 +124,25 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.fonfon.kgeohash.toGeoHash
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip96MediaServers
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.BechLink
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.removeQueryParamsForExtensionComparison
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
import com.vitorpamplona.amethyst.ui.note.CloseIcon
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
@@ -191,6 +185,8 @@ fun NewPostView(
onClose: () -> Unit,
baseReplyTo: Note? = null,
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@@ -206,7 +202,7 @@ fun NewPostView(
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel, baseReplyTo, quote)
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
@@ -385,6 +381,52 @@ fun NewPostView(
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (RichTextParser.isValidURL(myUrlPreview)) {
if (RichTextParser.isImageUrl(myUrlPreview)) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
)
} else if (RichTextParser.isVideoUrl(myUrlPreview)) {
VideoView(
myUrlPreview,
roundedCorner = true,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
)
} else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -434,34 +476,32 @@ fun NewPostView(
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (lud16 != null && postViewModel.wantsInvoice) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
accountViewModel.account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onClose = { postViewModel.wantsInvoice = false },
onError = { title, message -> accountViewModel.toast(title, message) },
)
if (postViewModel.wantsInvoice) {
postViewModel.lnAddress()?.let { lud16 ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
accountViewModel.account.userProfile().pubkeyHex,
accountViewModel.account,
stringResource(id = R.string.lightning_invoice),
stringResource(id = R.string.lightning_create_and_add_invoice),
onSuccess = {
postViewModel.insertAtCursor(it)
postViewModel.wantsInvoice = false
},
onClose = { postViewModel.wantsInvoice = false },
onError = { title, message -> accountViewModel.toast(title, message) },
)
}
}
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
if (postViewModel.wantsZapraiser && postViewModel.hasLnAddress()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@@ -472,54 +512,6 @@ fun NewPostView(
)
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
removeQueryParamsForExtensionComparison(myUrlPreview)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier =
Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colorScheme.subtleBorder,
QuoteBorder,
),
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(
myUrlPreview,
roundedCorner = true,
accountViewModel = accountViewModel,
)
} else {
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colorScheme.background
val backgroundColor = remember { mutableStateOf(bgColor) }
BechLink(
myUrlPreview,
true,
backgroundColor,
accountViewModel,
nav,
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
}
}
@@ -576,7 +568,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
}
if (postViewModel.canAddInvoice) {
if (postViewModel.canAddInvoice && postViewModel.hasLnAddress()) {
AddLnInvoiceButton(postViewModel.wantsInvoice) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
@@ -1178,23 +1170,12 @@ fun FowardZapTo(
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationAsHash(postViewModel: NewPostViewModel) {
val context = LocalContext.current
val locationPermissionState =
rememberPermissionState(
Manifest.permission.ACCESS_COARSE_LOCATION,
)
if (locationPermissionState.status.isGranted) {
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
DisposableEffect(key1 = Unit) {
postViewModel.startLocation(context = context)
locationDescriptionFlow = postViewModel.location
onDispose { postViewModel.stopLocation() }
}
Column(
modifier = Modifier.fillMaxWidth(),
) {
@@ -1225,7 +1206,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
modifier = Modifier.padding(start = 10.dp),
)
locationDescriptionFlow?.let { geoLocation -> DisplayLocationObserver(geoLocation) }
DisplayLocationObserver(postViewModel)
}
Divider()
@@ -1242,41 +1223,39 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
}
@Composable
fun DisplayLocationObserver(geoLocation: Flow<String>) {
val location by geoLocation.collectAsStateWithLifecycle(null)
fun DisplayLocationObserver(postViewModel: NewPostViewModel) {
val context = LocalContext.current
var locationDescriptionFlow by remember(postViewModel) { mutableStateOf<Flow<String>?>(null) }
location?.let { DisplayLocationInTitle(geohash = it) }
DisposableEffect(key1 = context) {
postViewModel.startLocation(context = context)
locationDescriptionFlow = postViewModel.location
onDispose { postViewModel.stopLocation() }
}
locationDescriptionFlow?.let {
val location by it.collectAsStateWithLifecycle(null)
location?.let { DisplayLocationInTitle(geohash = it) }
}
}
@Composable
fun DisplayLocationInTitle(geohash: String) {
val context = LocalContext.current
var cityName by remember(geohash) { mutableStateOf<String>(geohash) }
LaunchedEffect(key1 = geohash) {
launch(Dispatchers.IO) {
val newCityName =
ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank {
null
}
if (newCityName != null && newCityName != cityName) {
cityName = newCityName
}
}
}
if (geohash != "s0000") {
LoadCityName(
geohashStr = geohash,
onLoading = {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
},
) { cityName ->
Text(
text = cityName,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = Size5dp),
)
} else {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
}
}

View File

@@ -35,6 +35,8 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.insertUrlAtCursor
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -43,11 +45,9 @@ import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.Nip96Uploader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
@@ -55,9 +55,11 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
@@ -85,10 +87,10 @@ open class NewPostViewModel() : ViewModel() {
var requiresNIP24: Boolean = false
var originalNote: Note? = null
var forkedFromNote: Note? = null
var pTags by mutableStateOf<List<User>?>(null)
var eTags by mutableStateOf<List<Note>?>(null)
var imetaTags = mutableStateListOf<Array<String>>()
var nip94attachments by mutableStateOf<List<FileHeaderEvent>>(emptyList())
var nip95attachments by
@@ -162,10 +164,24 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
}
fun hasLnAddress(): Boolean {
return account?.userProfile()?.info?.lnAddress() != null
}
fun user(): User? {
return account?.userProfile()
}
open fun load(
accountViewModel: AccountViewModel,
replyingTo: Note?,
quote: Note?,
fork: Note?,
version: Note?,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
@@ -197,11 +213,6 @@ open class NewPostViewModel() : ViewModel() {
pTags = null
}
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
}
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
@@ -214,6 +225,75 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
}
if (!forwardZapTo.items.isEmpty()) {
wantsForwardZapTo = true
}
}
fun sendPost(relayList: List<Relay>? = null) {
@@ -355,6 +435,45 @@ open class NewPostViewModel() : ViewModel() {
nip94attachments = usedAttachments,
)
}
} else if (originalNote?.event is GitIssueEvent) {
val originalNoteEvent = originalNote?.event as GitIssueEvent
// adds markers
val rootId =
originalNoteEvent.rootIssueOrPatch() // if it has a marker as root
?: originalNote
?.replyTo
?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }
?.idHex // if it has loaded events with zero replies in the reply list
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
?: originalNote?.idHex
val replyId = originalNote?.idHex
val replyToSet =
if (forkedFromNote != null) {
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
} else {
tagger.eTags
}
val repositoryAddress = originalNoteEvent.repository()
account?.sendGitReply(
message = tagger.message,
replyTo = replyToSet,
mentions = tagger.pTags,
repository = repositoryAddress,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
)
} else {
if (wantsPoll) {
account?.sendPoll(
@@ -404,9 +523,16 @@ open class NewPostViewModel() : ViewModel() {
val replyId = originalNote?.idHex
val replyToSet =
if (forkedFromNote != null) {
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
} else {
tagger.eTags
}
account?.sendPost(
message = tagger.message,
replyTo = tagger.eTags,
replyTo = replyToSet,
mentions = tagger.pTags,
tags = null,
zapReceiver = zapReceiver,
@@ -415,6 +541,7 @@ open class NewPostViewModel() : ViewModel() {
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
forkedFrom = forkedFromNote?.event as? Event,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
@@ -504,7 +631,6 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = null
isUploadingImage = false
pTags = null
imetaTags.clear()
wantsDirectMessage = false
@@ -540,7 +666,7 @@ open class NewPostViewModel() : ViewModel() {
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word)
}
}
}
@@ -737,10 +863,7 @@ open class NewPostViewModel() : ViewModel() {
isUploadingImage = false
nip94attachments = nip94attachments + event
message =
TextFieldValue(
message.text + "\n" + imageUrl,
)
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
}
},
@@ -751,6 +874,10 @@ open class NewPostViewModel() : ViewModel() {
)
}
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
}
fun createNIP95Record(
bytes: ByteArray,
mimeType: String?,
@@ -777,7 +904,9 @@ open class NewPostViewModel() : ViewModel() {
isUploadingImage = false
note?.let { message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent()) }
note?.let {
message = message.insertUrlAtCursor("nostr:" + it.toNEvent())
}
urlPreview = findUrlInMessage()
}

View File

@@ -79,6 +79,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
@@ -193,8 +194,10 @@ fun NewRelayListView(
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) },
accountViewModel = accountViewModel,
nav = nav,
)
) {
onClose()
nav(it)
}
}
}
}
@@ -410,9 +413,14 @@ fun ServerConfigClickableLine(
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
item.briefInfo.favIcon,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)

View File

@@ -62,7 +62,9 @@ class NewRelayListViewModel : ViewModel() {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = { togglePaidRelay(item, it.limitation?.payment_required ?: false) },
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}

View File

@@ -48,7 +48,6 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.note.LoadUser
@@ -59,13 +58,14 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RelayInformationDialog(
onClose: () -> Unit,
relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
relayInfo: RelayInformation,
relayInfo: Nip11RelayInformation,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -104,7 +104,7 @@ fun RelayInformationDialog(
Column {
RenderRelayIcon(
relayBriefInfo.displayUrl,
relayBriefInfo.favIcon,
relayInfo.icon ?: relayBriefInfo.favIcon,
automaticallyShowProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
@@ -121,7 +121,12 @@ fun RelayInformationDialog(
Section(stringResource(R.string.owner))
relayInfo.pubkey?.let { DisplayOwnerInformation(it, accountViewModel, nav) }
relayInfo.pubkey?.let {
DisplayOwnerInformation(it, accountViewModel) {
onClose()
nav(it)
}
}
Section(stringResource(R.string.software))
@@ -170,12 +175,14 @@ fun RelayInformationDialog(
relayInfo.limitation?.let {
Section(stringResource(R.string.limitations))
val authRequired = it.auth_required ?: false
val authRequiredText =
if (authRequired) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequired = it.payment_required ?: false
if (it.auth_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val paymentRequiredText =
if (paymentRequired) stringResource(R.string.yes) else stringResource(R.string.no)
if (it.payment_required ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
val restrictedWritesText =
if (it.restricted_writes ?: false) stringResource(R.string.yes) else stringResource(R.string.no)
Column {
SectionContent(
@@ -184,7 +191,7 @@ fun RelayInformationDialog(
SectionContent(
"${stringResource(R.string.subscriptions)}: ${it.max_subscriptions ?: 0}",
)
SectionContent("${stringResource(R.string.filters)}: ${it.max_subscriptions ?: 0}")
SectionContent("${stringResource(R.string.filters)}: ${it.max_filters ?: 0}")
SectionContent(
"${stringResource(R.string.subscription_id_length)}: ${it.max_subid_length ?: 0}",
)
@@ -195,9 +202,13 @@ fun RelayInformationDialog(
SectionContent(
"${stringResource(R.string.content_length)}: ${it.max_content_length ?: 0}",
)
SectionContent(
"${stringResource(R.string.max_limit)}: ${it.max_limit ?: 0}",
)
SectionContent("${stringResource(R.string.minimum_pow)}: ${it.min_pow_difficulty ?: 0}")
SectionContent("${stringResource(R.string.auth)}: $authRequiredText")
SectionContent("${stringResource(R.string.payment)}: $paymentRequiredText")
SectionContent("${stringResource(R.string.restricted_writes)}: $restrictedWritesText")
}
}
@@ -236,7 +247,7 @@ fun RelayInformationDialog(
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplaySupportedNips(relayInfo: RelayInformation) {
private fun DisplaySupportedNips(relayInfo: Nip11RelayInformation) {
FlowRow {
relayInfo.supported_nips?.forEach { item ->
val text = item.toString().padStart(2, '0')
@@ -261,7 +272,7 @@ private fun DisplaySupportedNips(relayInfo: RelayInformation) {
}
@Composable
private fun DisplaySoftwareInformation(relayInfo: RelayInformation) {
private fun DisplaySoftwareInformation(relayInfo: Nip11RelayInformation) {
val url = (relayInfo.software ?: "").replace("git+", "")
Box(modifier = Modifier.padding(start = 10.dp)) {
ClickableUrl(

View File

@@ -47,11 +47,11 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -63,7 +63,7 @@ data class RelayList(
data class RelayInfoDialog(
val relayBriefInfo: RelayBriefInfoCache.RelayBriefInfo,
val relayInfo: RelayInformation,
val relayInfo: Nip11RelayInformation,
)
@Composable

View File

@@ -65,7 +65,7 @@ fun ChannelFabColumn(
if (wantsToSendNewMessage) {
NewPostView(
{ wantsToSendNewMessage = false },
onClose = { wantsToSendNewMessage = false },
enableMessageInterface = true,
accountViewModel = accountViewModel,
nav = nav,

View File

@@ -42,9 +42,9 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -66,7 +66,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.fasterxml.jackson.databind.node.TextNode
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.service.CashuProcessor
import com.vitorpamplona.amethyst.service.CachedCashuProcessor
import com.vitorpamplona.amethyst.service.CashuToken
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.note.CashuIcon
@@ -85,25 +85,26 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun CashuPreview(
cashutoken: String,
accountViewModel: AccountViewModel,
) {
var cachuData by remember {
mutableStateOf<GenericLoadable<CashuToken>>(GenericLoadable.Loading())
}
LaunchedEffect(key1 = cashutoken) {
launch(Dispatchers.IO) {
val newCachuData = CashuProcessor().parse(cashutoken)
launch(Dispatchers.Main) { cachuData = newCachuData }
val cashuData by produceState(
initialValue = CachedCashuProcessor.cached(cashutoken),
key1 = cashutoken,
) {
withContext(Dispatchers.IO) {
val newToken = CachedCashuProcessor.parse(cashutoken)
if (value != newToken) {
value = newToken
}
}
}
Crossfade(targetState = cachuData, label = "CashuPreview(") {
Crossfade(targetState = cashuData, label = "CashuPreview") {
when (it) {
is GenericLoadable.Loaded<CashuToken> -> CashuPreview(it.loaded, accountViewModel)
is GenericLoadable.Error<CashuToken> ->
@@ -160,17 +161,24 @@ fun CashuPreview(
Column(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.cashu),
@@ -193,11 +201,17 @@ fun CashuPreview(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
Row(
modifier = Modifier.padding(top = 5.dp).fillMaxWidth(),
modifier =
Modifier
.padding(top = 5.dp)
.fillMaxWidth(),
) {
var isRedeeming by remember { mutableStateOf(false) }
@@ -289,13 +303,17 @@ fun CashuPreviewNew(
Card(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -317,6 +335,7 @@ fun CashuPreviewNew(
Text(
text = "${token.totalAmount} ${stringResource(id = R.string.sats)}",
fontSize = 20.sp,
modifier = Modifier.padding(top = 5.dp),
)
Row(modifier = Modifier.padding(top = 5.dp)) {

View File

@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.LocalContentColor
@@ -31,7 +30,6 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -60,63 +58,79 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.Nip30CustomEmoji
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
word: String,
nip19: Nip19Bech32.ParseReturn,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, accountViewModel, nav)
when (val entity = nip19.entity) {
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.additionalChars, accountViewModel, nav)
is Nip19Bech32.NRelay -> {
Text(word)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, accountViewModel, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, accountViewModel, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, accountViewModel, nav)
is Nip19Bech32.NSec -> {
Text(word)
}
else -> {
Text(
remember { "@${nip19.hex}${nip19.additionalChars}" },
)
Text(word)
}
}
}
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
fun LoadOrCreateNote(
event: Event,
accountViewModel: AccountViewModel,
content: @Composable (Note?) -> Unit,
) {
var note by
remember(event.id) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(event.id)) }
if (note == null) {
LaunchedEffect(key1 = event.id) {
accountViewModel.checkGetOrCreateNote(event) { note = it }
}
}
content(note)
}
@Composable
private fun LoadAndDisplayEvent(
event: Event,
additionalChars: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LoadNote(nip19.hex, accountViewModel) {
LoadOrCreateNote(event, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, accountViewModel, nav)
DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
suffix = nip19.additionalChars,
route = remember(nip19) { "Event/${nip19.hex}" },
clickablePart = remember(event.id) { "@${event.toNIP19()}" },
suffix = additionalChars,
route = remember(event.id) { "Event/${event.id}" },
nav = nav,
)
}
@@ -124,19 +138,21 @@ private fun DisplayEvent(
}
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
private fun DisplayEvent(
hex: HexKey,
kind: Int?,
additionalChars: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LoadNote(nip19.hex, accountViewModel = accountViewModel) {
LoadNote(hex, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, accountViewModel, nav)
DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
suffix = nip19.additionalChars,
route = remember(nip19) { "Event/${nip19.hex}" },
clickablePart = remember(hex) { "@${hex.toShortenHex()}" },
suffix = additionalChars,
route = remember(hex) { "Event/$hex" },
nav = nav,
)
}
@@ -146,7 +162,9 @@ private fun DisplayNote(
@Composable
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
hex: HexKey,
kind: Int?,
addedCharts: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -156,21 +174,20 @@ private fun DisplayNoteLink(
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars}" }
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.KIND) {
if (note.event is ChannelCreateEvent || kind == ChannelCreateEvent.KIND) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
route = remember(noteState) { "Channel/$hex" },
nav = nav,
)
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.KIND) {
} else if (note.event is PrivateDmEvent || kind == PrivateDmEvent.KIND) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route =
remember(noteState) { (note.author?.pubkeyHex ?: nip19.hex).let { "RoomByAuthor/$it" } },
remember(noteState) { (note.author?.pubkeyHex ?: hex).let { "RoomByAuthor/$it" } },
nav = nav,
)
} else if (channelHex != null) {
@@ -192,7 +209,7 @@ private fun DisplayNoteLink(
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
route = remember(noteState) { "Event/$hex" },
nav = nav,
)
}
@@ -200,28 +217,28 @@ private fun DisplayNoteLink(
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
nip19: Nip19Bech32.NAddress,
additionalChars: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) }
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.atag)) }
if (noteBase == null) {
LaunchedEffect(key1 = nip19.hex) {
accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) { noteBase = it }
LaunchedEffect(key1 = nip19.atag) {
accountViewModel.checkGetOrCreateAddressableNote(nip19.atag) { noteBase = it }
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val route = remember(noteState) { "Note/${nip19.hex}" }
val route = remember(noteState) { "Note/${nip19.atag}" }
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars}" }
CreateClickableText(
clickablePart = displayName,
suffix = addedCharts,
suffix = additionalChars,
route = route,
nav = nav,
)
@@ -229,35 +246,36 @@ private fun DisplayAddress(
if (noteBase == null) {
Text(
remember { "@${nip19.hex}${nip19.additionalChars}" },
remember { "@${nip19.atag}$additionalChars" },
)
}
}
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
userHex: HexKey,
additionalChars: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var userBase by
remember(nip19) {
remember(userHex) {
mutableStateOf(
accountViewModel.getUserIfExists(nip19.hex),
accountViewModel.getUserIfExists(userHex),
)
}
if (userBase == null) {
LaunchedEffect(key1 = nip19.hex) {
accountViewModel.checkGetOrCreateUser(nip19.hex) { userBase = it }
LaunchedEffect(key1 = userHex) {
accountViewModel.checkGetOrCreateUser(userHex) { userBase = it }
}
}
userBase?.let { RenderUserAsClickableText(it, nip19, nav) }
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
if (userBase == null) {
Text(
remember { "@${nip19.hex}${nip19.additionalChars}" },
remember { "@${userHex}$additionalChars" },
)
}
}
@@ -265,7 +283,7 @@ private fun DisplayUser(
@Composable
private fun RenderUserAsClickableText(
baseUser: User,
nip19: Nip19.Return,
additionalChars: String,
nav: (String) -> Unit,
) {
val userState by baseUser.live().metadata.observeAsState()
@@ -282,12 +300,15 @@ private fun RenderUserAsClickableText(
userDisplayName?.let {
CreateClickableTextWithEmoji(
clickablePart = it,
suffix = nip19.additionalChars,
maxLines = 1,
route = route,
nav = nav,
tags = userTags,
)
additionalChars.ifBlank { null }?.let {
Text(text = it, maxLines = 1)
}
}
}
@@ -298,6 +319,7 @@ fun CreateClickableText(
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
route: String,
nav: (String) -> Unit,
) {
@@ -308,12 +330,14 @@ fun CreateClickableText(
remember(clickablePart, suffix) {
val clickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: primaryColor,
fontWeight = fontWeight,
)
val nonClickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: onBackgroundColor,
fontWeight = fontWeight,
)
@@ -368,6 +392,74 @@ fun ClickableText(
)
}
@Composable
fun CustomEmojiChecker(
text: String,
tags: ImmutableListOfLists<String>?,
onRegularText: @Composable (String) -> Unit,
onEmojiText: @Composable (ImmutableList<Nip30CustomEmoji.Renderable>) -> Unit,
) {
val mayContainEmoji by remember(text, tags) {
mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, tags))
}
if (mayContainEmoji) {
var emojiList by
remember(text, tags) {
mutableStateOf<ImmutableList<Nip30CustomEmoji.Renderable>?>(null)
}
LaunchedEffect(text, tags) {
val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, tags)
if (newEmojiList != null) {
emojiList = newEmojiList
}
}
emojiList?.let {
onEmojiText(it)
} ?: run {
onRegularText(text)
}
} else {
onRegularText(text)
}
}
@Composable
fun CustomEmojiChecker(
text: String,
emojis: ImmutableMap<String, String>,
onRegularText: @Composable (String) -> Unit,
onEmojiText: @Composable (ImmutableList<Nip30CustomEmoji.Renderable>) -> Unit,
) {
val mayContainEmoji by remember(text, emojis) {
mutableStateOf(Nip30CustomEmoji.fastMightContainEmoji(text, emojis))
}
if (mayContainEmoji) {
var emojiList by
remember(text, emojis) {
mutableStateOf<ImmutableList<Nip30CustomEmoji.Renderable>?>(null)
}
LaunchedEffect(text, emojis) {
val newEmojiList = Nip30CustomEmoji.assembleAnnotatedList(text, emojis)
if (newEmojiList != null) {
emojiList = newEmojiList
}
}
emojiList?.let {
onEmojiText(it)
} ?: run {
onRegularText(text)
}
} else {
onRegularText(text)
}
}
@Composable
fun CreateTextWithEmoji(
text: String,
@@ -380,52 +472,40 @@ fun CreateTextWithEmoji(
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier,
) {
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val emojis =
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(text, emojis)
if (newEmojiList.isNotEmpty()) {
emojiList = newEmojiList.toImmutableList()
}
}
}
}
val textColor =
color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } }
if (emojiList.isEmpty()) {
Text(
text = text,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier,
)
} else {
val style =
LocalTextStyle.current
.merge(
TextStyle(
color = textColor,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
)
.toSpanStyle()
CustomEmojiChecker(
text,
tags,
onEmojiText = {
val style =
LocalTextStyle.current
.merge(
TextStyle(
color = textColor,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
)
.toSpanStyle()
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
}
InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier)
},
onRegularText = {
Text(
text = it,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier,
)
},
)
}
@Composable
@@ -440,51 +520,43 @@ fun CreateTextWithEmoji(
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier,
) {
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
if (emojis.isNotEmpty()) {
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val newEmojiList = assembleAnnotatedList(text, emojis)
if (newEmojiList.isNotEmpty()) {
emojiList = newEmojiList.toImmutableList()
}
}
}
}
val textColor =
color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current } }
if (emojiList.isEmpty()) {
Text(
text = text,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier,
)
} else {
val currentStyle = LocalTextStyle.current
val style =
remember(currentStyle) {
currentStyle
.merge(
TextStyle(
color = textColor,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
)
.toSpanStyle()
}
CustomEmojiChecker(
text,
emojis,
onEmojiText = {
val currentStyle = LocalTextStyle.current
val style =
remember(currentStyle) {
currentStyle
.merge(
TextStyle(
color = textColor,
textAlign = TextAlign.Unspecified,
fontWeight = fontWeight,
fontSize = fontSize,
),
)
.toSpanStyle()
}
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
}
InLineIconRenderer(it, style, fontSize, maxLines, overflow, modifier)
},
onRegularText = {
Text(
text = it,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier,
)
},
)
}
@Composable
@@ -495,126 +567,62 @@ fun CreateClickableTextWithEmoji(
style: TextStyle,
onClick: (Int) -> Unit,
) {
var emojiList by
remember(clickablePart) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
if (newEmojiList.isNotEmpty()) {
emojiList = newEmojiList.toImmutableList()
}
}
}
}
if (emojiList.isEmpty()) {
ClickableText(
text = AnnotatedString(clickablePart),
style = style,
maxLines = maxLines,
onClick = onClick,
)
} else {
ClickableInLineIconRenderer(emojiList, maxLines, style.toSpanStyle()) { onClick(it) }
}
CustomEmojiChecker(
text = clickablePart,
tags = tags,
onRegularText = {
ClickableText(
text = AnnotatedString(clickablePart),
style = style,
maxLines = maxLines,
onClick = onClick,
)
},
onEmojiText = {
ClickableInLineIconRenderer(it, maxLines, style.toSpanStyle()) { onClick(it) }
},
)
}
@Immutable
data class DoubleEmojiList(
val part1: ImmutableList<Renderable>,
val part2: ImmutableList<Renderable>,
)
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String?,
maxLines: Int = Int.MAX_VALUE,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
fontSize: TextUnit = TextUnit.Unspecified,
route: String,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>?,
) {
var emojiLists by remember(clickablePart) { mutableStateOf<DoubleEmojiList?>(null) }
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] }
?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
val newEmojiList2 =
suffix?.let { assembleAnnotatedList(it, emojis) } ?: emptyList<Renderable>()
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
emojiLists =
DoubleEmojiList(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
}
}
}
}
if (emojiLists == null) {
CreateClickableText(clickablePart, suffix, maxLines, overrideColor, fontWeight, route, nav)
} else {
ClickableInLineIconRenderer(
emojiLists!!.part1,
maxLines,
LocalTextStyle.current
.copy(color = overrideColor ?: MaterialTheme.colorScheme.primary, fontWeight = fontWeight)
.toSpanStyle(),
) {
nav(route)
}
InLineIconRenderer(
emojiLists!!.part2,
LocalTextStyle.current
.copy(
color = overrideColor ?: MaterialTheme.colorScheme.onBackground,
CustomEmojiChecker(
text = clickablePart,
tags = tags,
onRegularText = {
CreateClickableText(it, null, maxLines, overrideColor, fontWeight, fontSize, route, nav)
},
onEmojiText = {
val clickablePartStyle =
SpanStyle(
fontSize = fontSize,
color = overrideColor ?: MaterialTheme.colorScheme.primary,
fontWeight = fontWeight,
)
.toSpanStyle(),
maxLines = maxLines,
)
}
}
suspend fun assembleAnnotatedList(
text: String,
emojis: Map<String, String>,
): ImmutableList<Renderable> {
return Nip30CustomEmoji()
.buildArray(text)
.map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)
} else {
TextType(it)
ClickableInLineIconRenderer(
it,
maxLines,
clickablePartStyle,
) {
nav(route)
}
}
.toImmutableList()
},
)
}
@Immutable open class Renderable()
@Immutable class TextType(val text: String) : Renderable()
@Immutable class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(
wordsInOrder: ImmutableList<Renderable>,
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
maxLines: Int = Int.MAX_VALUE,
style: SpanStyle,
onClick: (Int) -> Unit,
@@ -631,7 +639,7 @@ fun ClickableInLineIconRenderer(
val inlineContent =
wordsInOrder
.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
if (value is Nip30CustomEmoji.ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
@@ -660,9 +668,9 @@ fun ClickableInLineIconRenderer(
withStyle(
style,
) {
if (value is TextType) {
if (value is Nip30CustomEmoji.TextType) {
append(value.text)
} else if (value is ImageUrlType) {
} else if (value is Nip30CustomEmoji.ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
@@ -677,7 +685,7 @@ fun ClickableInLineIconRenderer(
}
}
BasicText(
Text(
text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent,
@@ -688,7 +696,7 @@ fun ClickableInLineIconRenderer(
@Composable
fun InLineIconRenderer(
wordsInOrder: ImmutableList<Renderable>,
wordsInOrder: ImmutableList<Nip30CustomEmoji.Renderable>,
style: SpanStyle,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
@@ -707,7 +715,7 @@ fun InLineIconRenderer(
val inlineContent =
wordsInOrder
.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
if (value is Nip30CustomEmoji.ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
@@ -737,9 +745,9 @@ fun InLineIconRenderer(
withStyle(
style,
) {
if (value is TextType) {
if (value is Nip30CustomEmoji.TextType) {
append(value.text)
} else if (value is ImageUrlType) {
} else if (value is Nip30CustomEmoji.ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}

View File

@@ -43,7 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ExpandableTextParser
import com.vitorpamplona.amethyst.commons.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -63,7 +63,7 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
val whereToCut = remember(content) { ExpandableTextParser.computeWhereToCutIfPostIsTooLong(content) }
val whereToCut = remember(content) { ExpandableTextCutOffCalculator.indexToCutOff(content) }
val text by
remember(content) {

View File

@@ -34,10 +34,9 @@ import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -52,43 +51,30 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.CachedLnInvoiceParser
import com.vitorpamplona.amethyst.service.lnurl.InvoiceAmount
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.text.NumberFormat
@Stable data class InvoiceAmount(val invoice: String, val amount: String?)
import kotlinx.coroutines.withContext
@Composable
fun LoadValueFromInvoice(
lnbcWord: String,
inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit,
) {
var lnInvoice by remember { mutableStateOf<InvoiceAmount?>(null) }
LaunchedEffect(key1 = lnbcWord) {
launch(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount =
try {
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
null
}
lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
val lnInvoice by
produceState(initialValue = CachedLnInvoiceParser.cached(lnbcWord), key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val newLnInvoice = CachedLnInvoiceParser.parse(lnbcWord)
if (value != newLnInvoice) {
value = newLnInvoice
}
}
}
}
inner(lnInvoice)
}
@@ -128,17 +114,24 @@ fun InvoicePreview(
Column(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, top = 10.dp, bottom = 10.dp)
.clip(shape = QuoteBorder)
.border(1.dp, MaterialTheme.colorScheme.subtleBorder, QuoteBorder),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(20.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(20.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
) {
Icon(
painter = painterResource(R.drawable.lightning),
@@ -162,12 +155,18 @@ fun InvoicePreview(
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = { payViaIntent(lnInvoice, context) { showErrorMessageDialog = it } },
shape = QuoteBorder,
colors =

View File

@@ -24,15 +24,15 @@ import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import kotlinx.collections.immutable.persistentListOf
@Composable
fun LoadUrlPreview(
@@ -45,19 +45,16 @@ fun LoadUrlPreview(
if (!automaticallyShowUrlPreview) {
ClickableUrl(urlText, url)
} else {
var urlPreviewState by
remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
)
val urlPreviewState by
produceState(
initialValue = UrlCachedPreviewer.cache.get(url) ?: UrlPreviewState.Loading,
key1 = url,
) {
if (value == UrlPreviewState.Loading) {
accountViewModel.urlPreview(url) { value = it }
}
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are
// created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) { accountViewModel.urlPreview(url) { urlPreviewState = it } }
}
Crossfade(
targetState = urlPreviewState,
animationSpec = tween(durationMillis = 100),
@@ -68,19 +65,17 @@ fun LoadUrlPreview(
if (state.previewInfo.mimeType.type == "image") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
ZoomableUrlImage(url),
persistentListOf(),
content = MediaUrlImage(url),
roundedCorner = true,
accountViewModel,
accountViewModel = accountViewModel,
)
}
} else if (state.previewInfo.mimeType.type == "video") {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
ZoomableUrlVideo(url),
persistentListOf(),
content = MediaUrlVideo(url),
roundedCorner = true,
accountViewModel,
accountViewModel = accountViewModel,
)
}
} else {

View File

@@ -22,10 +22,11 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.coroutines.CancellationException
@@ -34,7 +35,7 @@ class MarkdownParser {
tag: String,
tags: ImmutableListOfLists<String>,
): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val matcher = RichTextParser.tagIndex.matcher(tag)
val (index, suffix) =
try {
matcher.find()
@@ -64,67 +65,88 @@ class MarkdownParser {
return null
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
if (nip19.type == Nip19.Type.USER) {
LocalCache.users[nip19.hex]?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
private suspend fun getDisplayNameFromNip19(nip19: Nip19Bech32.Entity): Pair<String, String>? {
return when (nip19) {
is Nip19Bech32.NSec -> null
is Nip19Bech32.NPub -> {
LocalCache.getUserIfExists(nip19.hex)?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
}
} else if (nip19.type == Nip19.Type.NOTE) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
is Nip19Bech32.NProfile -> {
LocalCache.getUserIfExists(nip19.hex)?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
}
} else if (nip19.type == Nip19.Type.ADDRESS) {
LocalCache.addressables[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
is Nip19Bech32.Note -> {
LocalCache.getNoteIfExists(nip19.hex)?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
} else if (nip19.type == Nip19.Type.EVENT) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
is Nip19Bech32.NEvent -> {
LocalCache.getNoteIfExists(nip19.hex)?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
}
is Nip19Bech32.NEmbed -> {
if (LocalCache.getNoteIfExists(nip19.event.id) == null) {
LocalCache.verifyAndConsume(nip19.event, null)
}
return null
LocalCache.getNoteIfExists(nip19.event.id)?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
is Nip19Bech32.NRelay -> null
is Nip19Bech32.NAddress -> {
LocalCache.getAddressableNoteIfExists(nip19.atag)?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
else -> null
}
}
fun returnNIP19References(
content: String,
tags: ImmutableListOfLists<String>?,
): List<Nip19.Return> {
): List<Nip19Bech32.Entity> {
checkNotInMainThread()
val listOfReferences = mutableListOf<Nip19.Return>()
val listOfReferences = mutableListOf<Nip19Bech32.Entity>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let { listOfReferences.add(it) }
if (RichTextParser.startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19Bech32.uriToRoute(word)
parsedNip19?.let { listOfReferences.add(it.entity) }
}
}
}
tags?.lists?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
listOfReferences.add(Nip19Bech32.NProfile(it[1], listOfNotNull(it.getOrNull(2))))
} else if (it[0] == "e" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
listOfReferences.add(Nip19Bech32.NEvent(it[1], listOfNotNull(it.getOrNull(2)), null, null))
} else if (it[0] == "a" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
ATag.parseAtag(it[1], it.getOrNull(2))?.let { atag ->
listOfReferences.add(Nip19Bech32.NAddress(it[1], listOfNotNull(atag.relay), atag.pubKeyHex, atag.kind))
}
}
}
return listOfReferences
}
fun returnMarkdownWithSpecialContent(
suspend fun returnMarkdownWithSpecialContent(
content: String,
tags: ImmutableListOfLists<String>?,
): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(word)
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
if (RichTextParser.isValidURL(word)) {
if (RichTextParser.isImageUrl(word)) {
returnContent += "![]($word) "
} else {
returnContent += "[$word]($word) "
@@ -133,11 +155,11 @@ class MarkdownParser {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
} else if (RichTextParser.startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19Bech32.uriToRoute(word)
returnContent +=
if (parsedNip19 !== null) {
val pair = getDisplayNameFromNip19(parsedNip19)
if (parsedNip19?.entity !== null) {
val pair = getDisplayNameFromNip19(parsedNip19.entity)
if (pair != null) {
val (displayName, nip19) = pair
"[$displayName](nostr:$nip19) "
@@ -148,15 +170,15 @@ class MarkdownParser {
"$word "
}
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
if (RichTextParser.tagIndex.matcher(word).matches() && tags != null) {
val pair = getDisplayNameAndNIP19FromTag(word, tags)
if (pair != null) {
returnContent += "[${pair.first}](nostr:${pair.second}) "
} else {
returnContent += "$word "
}
} else if (hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = hashTagsPattern.matcher(word)
} else if (RichTextParser.hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = RichTextParser.hashTagsPattern.matcher(word)
val (myTag, mySuffix) =
try {

View File

@@ -65,34 +65,38 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.material3.Material3RichText
import com.vitorpamplona.amethyst.commons.BechSegment
import com.vitorpamplona.amethyst.commons.CashuSegment
import com.vitorpamplona.amethyst.commons.EmailSegment
import com.vitorpamplona.amethyst.commons.EmojiSegment
import com.vitorpamplona.amethyst.commons.HashIndexEventSegment
import com.vitorpamplona.amethyst.commons.HashIndexUserSegment
import com.vitorpamplona.amethyst.commons.HashTagSegment
import com.vitorpamplona.amethyst.commons.ImageSegment
import com.vitorpamplona.amethyst.commons.InvoiceSegment
import com.vitorpamplona.amethyst.commons.LinkSegment
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.PhoneSegment
import com.vitorpamplona.amethyst.commons.RegularTextSegment
import com.vitorpamplona.amethyst.commons.RichTextParser
import com.vitorpamplona.amethyst.commons.RichTextViewerState
import com.vitorpamplona.amethyst.commons.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.Segment
import com.vitorpamplona.amethyst.commons.WithdrawSegment
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HashtagIcon
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.BechSegment
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.CashuSegment
import com.vitorpamplona.amethyst.service.EmailSegment
import com.vitorpamplona.amethyst.service.EmojiSegment
import com.vitorpamplona.amethyst.service.HashIndexEventSegment
import com.vitorpamplona.amethyst.service.HashIndexUserSegment
import com.vitorpamplona.amethyst.service.HashTagSegment
import com.vitorpamplona.amethyst.service.ImageSegment
import com.vitorpamplona.amethyst.service.InvoiceSegment
import com.vitorpamplona.amethyst.service.LinkSegment
import com.vitorpamplona.amethyst.service.PhoneSegment
import com.vitorpamplona.amethyst.service.RegularTextSegment
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.service.SchemelessUrlSegment
import com.vitorpamplona.amethyst.service.Segment
import com.vitorpamplona.amethyst.service.WithdrawSegment
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
import com.vitorpamplona.amethyst.ui.theme.Font17SP
@@ -101,50 +105,16 @@ import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Pattern =
Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
fun removeQueryParamsForExtensionComparison(fullUrl: String): String {
return if (fullUrl.contains("?")) {
fullUrl.split("?")[0].lowercase()
} else if (fullUrl.contains("#")) {
fullUrl.split("#")[0].lowercase()
} else {
fullUrl.lowercase()
}
}
fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } ||
videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
fun isValidURL(url: String?): Boolean {
return try {
URL(url).toURI()
true
} catch (e: MalformedURLException) {
false
} catch (e: URISyntaxException) {
false
}
}
fun isMarkdown(content: String): Boolean {
return content.startsWith("> ") ||
@@ -174,15 +144,9 @@ fun RichTextViewer(
}
}
@Preview()
@Preview
@Composable
fun RenderRegularPreview() {
val backgroundColor = MaterialTheme.colorScheme.background
val backgroundColorState =
remember {
mutableStateOf(backgroundColor)
}
val nav: (String) -> Unit = {}
Column(modifier = Modifier.padding(10.dp)) {
@@ -209,6 +173,7 @@ fun RenderRegularPreview() {
nav = nav,
)
}
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
@@ -216,27 +181,93 @@ fun RenderRegularPreview() {
is RegularTextSegment -> Text(word.segmentText)
}
}
}
}
RenderRegular(
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
EmptyTagList,
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
@Preview
@Composable
fun RenderRegularPreview2() {
val nav: (String) -> Unit = {}
RenderRegular(
"#Amethyst v0.84.1: ncryptsec support (NIP-49)",
EmptyTagList,
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
// is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
}
}
@Composable
fun mockAccountViewModel(): AccountViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
return AccountViewModel(
Account(
// blank keys
keyPair =
KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
forcePubKeyCheck = false,
),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
),
sharedPreferencesViewModel.sharedPrefs,
)
}
@Preview
@Composable
fun RenderRegularPreview3() {
val tags =
ImmutableListOfLists(
arrayOf(
arrayOf("t", "ioメシヨソイゲーム"),
arrayOf("emoji", "_ri", "https://media.misskeyusercontent.com/emoji/_ri.png"),
arrayOf("emoji", "petthex_japanesecake", "https://media.misskeyusercontent.com/emoji/petthex_japanesecake.gif"),
arrayOf("emoji", "ai_nomming", "https://media.misskeyusercontent.com/misskey/f6294900-f678-43cc-bc36-3ee5deeca4c2.gif"),
arrayOf("proxy", "https://misskey.io/notes/9q0x6gtdysir03qh", "activitypub"),
),
)
val nav: (String) -> Unit = {}
val accountViewModel = mockAccountViewModel()
RenderRegular(
"\u200B:_ri:\u200B\u200B:_ri:\u200Bはベイクドモチョチョ\u200B:petthex_japanesecake:\u200Bを食べました\u200B:ai_nomming:\u200B\n" +
"#ioメシヨソイゲーム\n" +
"https://misskey.io/play/9g3qza4jow",
tags,
) { word, state ->
when (word) {
// is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
// is CashuSegment -> CashuPreview(word.segmentText, accountViewModel)
is EmailSegment -> ClickableEmail(word.segmentText)
is PhoneSegment -> ClickablePhone(word.segmentText)
// is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
// is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
// is HashIndexEventSegment -> TagLink(word, true, backgroundColorState, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText)
}
}
}
@@ -313,6 +344,17 @@ private fun RenderRegular(
}
}
}
/*
// UrlPreviews and Images have a 5dp spacing down. This also adds the space to Text.
val lastElement = state.paragraphs.lastOrNull()?.words?.lastOrNull()
if (lastElement !is ImageSegment &&
lastElement !is LinkSegment &&
lastElement !is InvoiceSegment &&
lastElement !is CashuSegment
) {
Spacer(modifier = StdVertSpacer)
}*/
}
}
@@ -420,7 +462,7 @@ fun RenderCustomEmoji(
val markdownParseOptions =
MarkdownParseOptions(
autolink = true,
isImage = { url -> isImageOrVideoUrl(url) },
isImage = { url -> RichTextParser.isImageOrVideoUrl(url) },
)
@Composable
@@ -455,7 +497,11 @@ private fun RenderContentAsMarkdown(
ZoomableContentView(
content =
remember(destination, tags) {
RichTextParser().parseMediaUrl(destination, tags ?: EmptyTagList) ?: ZoomableUrlImage(url = destination)
RichTextParser().parseMediaUrl(
destination,
tags ?: EmptyTagList,
title.ifEmpty { null } ?: content,
) ?: MediaUrlImage(url = destination, description = title.ifEmpty { null } ?: content)
},
roundedCorner = true,
accountViewModel = accountViewModel,
@@ -477,9 +523,7 @@ private fun RefreshableContent(
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
ObserverAllNIP19References(content, tags, accountViewModel) {
accountViewModel.returnMarkdownWithSpecialContent(content, tags) {
newMarkdownWithSpecialContent,
->
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
@@ -496,7 +540,7 @@ fun ObserverAllNIP19References(
accountViewModel: AccountViewModel,
onRefresh: () -> Unit,
) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var nip19References by remember(content) { mutableStateOf<List<Nip19Bech32.Entity>>(emptyList()) }
LaunchedEffect(key1 = content) {
accountViewModel.returnNIP19References(content, tags) {
@@ -510,33 +554,37 @@ fun ObserverAllNIP19References(
@Composable
fun ObserveNIP19(
it: Nip19.Return,
entity: Nip19Bech32.Entity,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit,
) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
ObserveNIP19Event(it, accountViewModel, onRefresh)
} else if (it.type == Nip19.Type.USER) {
ObserveNIP19User(it, accountViewModel, onRefresh)
when (val parsed = entity) {
is Nip19Bech32.NPub -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NProfile -> ObserveNIP19User(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.Note -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEvent -> ObserveNIP19Event(parsed.hex, accountViewModel, onRefresh)
is Nip19Bech32.NEmbed -> ObserveNIP19Event(parsed.event.id, accountViewModel, onRefresh)
is Nip19Bech32.NAddress -> ObserveNIP19Event(parsed.atag, accountViewModel, onRefresh)
is Nip19Bech32.NSec -> {}
is Nip19Bech32.NRelay -> {}
}
}
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
hex: HexKey,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit,
) {
var baseNote by remember(it) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(it.hex)) }
var baseNote by remember(hex) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(hex)) }
if (baseNote == null) {
LaunchedEffect(key1 = it.hex) {
if (
it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS
) {
accountViewModel.checkGetOrCreateNote(it.hex) { note ->
launch(Dispatchers.Main) { baseNote = note }
}
LaunchedEffect(key1 = hex) {
accountViewModel.checkGetOrCreateNote(hex) { note ->
launch(Dispatchers.Main) { baseNote = note }
}
}
}
@@ -560,18 +608,16 @@ fun ObserveNote(
@Composable
private fun ObserveNIP19User(
it: Nip19.Return,
hex: HexKey,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit,
) {
var baseUser by remember(it) { mutableStateOf<User?>(accountViewModel.getUserIfExists(it.hex)) }
var baseUser by remember(hex) { mutableStateOf<User?>(accountViewModel.getUserIfExists(hex)) }
if (baseUser == null) {
LaunchedEffect(key1 = it.hex) {
if (it.type == Nip19.Type.USER) {
accountViewModel.checkGetOrCreateUser(it.hex)?.let { user ->
launch(Dispatchers.Main) { baseUser = user }
}
LaunchedEffect(key1 = hex) {
accountViewModel.checkGetOrCreateUser(hex)?.let { user ->
launch(Dispatchers.Main) { baseUser = user }
}
}
}
@@ -604,7 +650,9 @@ fun BechLink(
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
if (loadedLink == null) {
LaunchedEffect(key1 = word) { accountViewModel.parseNIP19(word) { loadedLink = it } }
LaunchedEffect(key1 = word) {
accountViewModel.parseNIP19(word) { loadedLink = it }
}
}
if (canPreview && loadedLink?.baseNote != null) {
@@ -614,11 +662,11 @@ fun BechLink(
accountViewModel,
backgroundColor,
nav,
loadedLink!!,
loadedLink?.nip19?.additionalChars?.ifBlank { null },
)
}
} else if (loadedLink?.nip19 != null) {
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
ClickableRoute(word, loadedLink?.nip19!!, accountViewModel, nav)
} else {
val text =
remember(word) {
@@ -639,7 +687,7 @@ private fun DisplayFullNote(
accountViewModel: AccountViewModel,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
loadedLink: LoadedBechLink,
extraChars: String?,
) {
NoteCompose(
baseNote = it,
@@ -650,8 +698,6 @@ private fun DisplayFullNote(
nav = nav,
)
val extraChars = remember(loadedLink) { loadedLink.nip19.additionalChars.ifBlank { null } }
extraChars?.let {
Text(
it,
@@ -667,23 +713,25 @@ fun HashTag(
val primary = MaterialTheme.colorScheme.primary
val background = MaterialTheme.colorScheme.onBackground
val hashtagIcon: HashtagIcon? =
remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) }
remember(segment.segmentText) { checkForHashtagWithIcon(segment.hashtag, primary) }
val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) }
val annotatedTermsString =
remember {
remember(segment.segmentText) {
buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToHashtag", "")
append("#${segment.hashtag}")
pop()
}
if (hashtagIcon != null) {
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToHashtag", "")
appendInlineContent("inlineContent", "[icon]")
pop()
}
}
@@ -734,7 +782,12 @@ fun TagLink(
if (it == null) {
Text(text = word.segmentText)
} else {
Row { DisplayUserFromTag(it, word.extras, nav) }
Row {
DisplayUserFromTag(it, nav)
word.extras?.let {
Text(text = it)
}
}
}
}
}
@@ -811,7 +864,6 @@ private fun DisplayNoteFromTag(
@Composable
private fun DisplayUserFromTag(
baseUser: User,
addedChars: String?,
nav: (String) -> Unit,
) {
val route = remember { "User/${baseUser.pubkeyHex}" }
@@ -819,12 +871,11 @@ private fun DisplayUserFromTag(
val meta by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
Crossfade(targetState = meta) {
Crossfade(targetState = meta, label = "DisplayUserFromTag") {
Row {
val displayName = remember(it) { it?.bestDisplayName() ?: it?.bestUsername() ?: hex }
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = addedChars,
maxLines = 1,
route = route,
nav = nav,

View File

@@ -30,9 +30,10 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.Robohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.utils.Robohash
import okio.Buffer
import okio.buffer
import okio.source
import java.nio.charset.Charset
@Stable
@@ -43,14 +44,10 @@ class HashImageFetcher(
) : Fetcher {
override suspend fun fetch(): SourceResult {
checkNotInMainThread()
val source =
try {
val buffer = Buffer()
buffer.writeString(
Robohash.assemble(data.toString(), isLightTheme),
Charset.defaultCharset(),
)
buffer
Robohash.assemble(data.toString(), isLightTheme).byteInputStream(Charset.defaultCharset()).source().buffer()
} finally {
}

View File

@@ -32,6 +32,15 @@ class SplitItem<T>(val key: T) {
class Split<T>() {
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
fun addItem(
key: T,
percentage: Float,
) {
val newItem = SplitItem(key)
newItem.percentage = percentage
this.items = items.plus(newItem)
}
fun addItem(key: T): Int {
val wasEqualSplit = isEqualSplit()
val newItem = SplitItem(key)

View File

@@ -69,7 +69,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SideEffect
@@ -105,8 +104,15 @@ import androidx.core.view.ViewCompat
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.imageLoader
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.BaseMediaContent
import com.vitorpamplona.amethyst.commons.MediaLocalImage
import com.vitorpamplona.amethyst.commons.MediaLocalVideo
import com.vitorpamplona.amethyst.commons.MediaPreloadedContent
import com.vitorpamplona.amethyst.commons.MediaUrlContent
import com.vitorpamplona.amethyst.commons.MediaUrlImage
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
@@ -137,104 +143,17 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
@Immutable
abstract class ZoomableContent(
val description: String? = null,
val dim: String? = null,
)
@Immutable
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
dim: String? = null,
val uri: String? = null,
) : ZoomableContent(description, dim)
@Immutable
class ZoomableUrlImage(
url: String,
description: String? = null,
hash: String? = null,
val blurhash: String? = null,
dim: String? = null,
uri: String? = null,
val contentWarning: String? = null,
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
class ZoomableUrlVideo(
url: String,
description: String? = null,
hash: String? = null,
dim: String? = null,
uri: String? = null,
val artworkUri: String? = null,
val authorName: String? = null,
val blurhash: String? = null,
val contentWarning: String? = null,
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
abstract class ZoomablePreloadedContent(
val localFile: File?,
description: String? = null,
val mimeType: String? = null,
val isVerified: Boolean? = null,
dim: String? = null,
val uri: String,
) : ZoomableContent(description, dim)
@Immutable
class ZoomableLocalImage(
localFile: File?,
mimeType: String? = null,
description: String? = null,
val blurhash: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String,
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
@Immutable
class ZoomableLocalVideo(
localFile: File?,
mimeType: String? = null,
description: String? = null,
dim: String? = null,
isVerified: Boolean? = null,
uri: String,
val artworkUri: String? = null,
val authorName: String? = null,
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableUrlImage(fullUrl)
} else if (isVideo) {
ZoomableUrlVideo(fullUrl)
} else {
ZoomableUrlImage(fullUrl)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(
content: ZoomableContent,
images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList(),
content: BaseMediaContent,
images: ImmutableList<BaseMediaContent> = remember(content) { listOf(content).toImmutableList() },
roundedCorner: Boolean,
accountViewModel: AccountViewModel,
) {
// store the dialog open or close state
var dialogOpen by remember { mutableStateOf(false) }
var dialogOpen by remember(content) { mutableStateOf(false) }
// store the dialog open or close state
val shareOpen = remember { mutableStateOf(false) }
@@ -250,13 +169,13 @@ fun ZoomableContentView(
Modifier.fillMaxWidth()
}
if (content is ZoomableUrlContent) {
if (content is MediaUrlContent) {
mainImageModifier =
mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { shareOpen.value = true },
)
} else if (content is ZoomablePreloadedContent) {
} else if (content is MediaPreloadedContent) {
mainImageModifier =
mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
@@ -267,11 +186,11 @@ fun ZoomableContentView(
}
when (content) {
is ZoomableUrlImage ->
is MediaUrlImage ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
}
is ZoomableUrlVideo ->
is MediaUrlVideo ->
SensitivityWarning(content.contentWarning != null, accountViewModel) {
VideoView(
videoUri = content.url,
@@ -286,9 +205,9 @@ fun ZoomableContentView(
accountViewModel = accountViewModel,
)
}
is ZoomableLocalImage ->
is MediaLocalImage ->
LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel)
is ZoomableLocalVideo ->
is MediaLocalVideo ->
content.localFile?.let {
VideoView(
videoUri = it.toUri().toString(),
@@ -310,13 +229,13 @@ fun ZoomableContentView(
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
content: MediaLocalImage,
mainImageModifier: Modifier,
topPaddingForControllers: Dp = Dp.Unspecified,
accountViewModel: AccountViewModel,
alwayShowImage: Boolean = false,
) {
if (content.localFile != null && content.localFile.exists()) {
if (content.localFileExists()) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
val showImage =
remember {
@@ -377,7 +296,7 @@ private fun LocalImageView(
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
content: MediaUrlImage,
mainImageModifier: Modifier,
topPaddingForControllers: Dp = Dp.Unspecified,
accountViewModel: AccountViewModel,
@@ -386,7 +305,7 @@ private fun UrlImageView(
BoxWithConstraints(contentAlignment = Alignment.Center) {
val showImage =
remember {
mutableStateOf<Boolean>(
mutableStateOf(
if (alwayShowImage) true else accountViewModel.settings.showImages.value,
)
}
@@ -394,19 +313,9 @@ private fun UrlImageView(
val myModifier =
remember {
mainImageModifier.widthIn(max = maxWidth).heightIn(max = maxHeight)
/* Is this necessary? It makes images bleed into other pages
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
*/
}
val contentScale =
remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
val verifierModifier =
if (topPaddingForControllers.isSpecified) {
@@ -423,7 +332,9 @@ private fun UrlImageView(
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = { painterState.value = it },
onState = {
painterState.value = it
},
)
}
@@ -457,11 +368,13 @@ fun ImageUrlWithDownloadButton(
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToImage", "")
append("$url ")
pop()
}
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToImage", "")
appendInlineContent("inlineContent", "[icon]")
pop()
}
withStyle(regularText) { append(" ") }
@@ -500,7 +413,7 @@ private fun InlineDownloadIcon(showImage: MutableState<Boolean>) =
@OptIn(ExperimentalLayoutApi::class)
private fun AddedImageFeatures(
painter: MutableState<AsyncImagePainter.State?>,
content: ZoomableLocalImage,
content: MediaLocalImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier,
@@ -549,8 +462,8 @@ private fun AddedImageFeatures(
BlankNote()
}
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
content.isVerified?.let {
HashVerificationSymbol(it, verifiedModifier)
}
}
else -> {}
@@ -562,13 +475,13 @@ private fun AddedImageFeatures(
@OptIn(ExperimentalLayoutApi::class)
private fun AddedImageFeatures(
painter: MutableState<AsyncImagePainter.State?>,
content: ZoomableUrlImage,
content: MediaUrlImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier,
showImage: MutableState<Boolean>,
) {
val ratio = remember { aspectRatio(content.dim) }
val ratio = remember(content.url) { aspectRatio(content.dim) }
if (!showImage.value) {
if (content.blurhash != null && ratio != null) {
@@ -588,7 +501,7 @@ private fun AddedImageFeatures(
ImageUrlWithDownloadButton(content.url, showImage)
}
} else {
var verifiedHash by remember { mutableStateOf<Boolean?>(null) }
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
when (painter.value) {
null,
@@ -616,10 +529,9 @@ private fun AddedImageFeatures(
}
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
val context = LocalContext.current
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
val newVerifiedHash = verifyHash(content)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
@@ -657,8 +569,8 @@ fun aspectRatio(dim: String?): Float? {
}
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
private fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
var cnt by remember { mutableStateOf<BaseMediaContent?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
@@ -671,7 +583,7 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
}
@Composable
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
private fun DisplayUrlWithLoadingSymbolWait(content: BaseMediaContent) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -683,10 +595,11 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
val annotatedTermsString =
remember {
buildAnnotatedString {
if (content is ZoomableUrlContent) {
if (content is MediaUrlContent) {
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToImage", "")
append(content.url + " ")
pop()
}
} else {
withStyle(regularText) { append("Loading content...") }
@@ -695,6 +608,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
withStyle(clickableTextStyle) {
pushStringAnnotation("routeToImage", "")
appendInlineContent("inlineContent", "[icon]")
pop()
}
withStyle(regularText) { append(" ") }
@@ -705,7 +619,7 @@ private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
val pressIndicator =
remember {
if (content is ZoomableUrlContent) {
if (content is MediaUrlContent) {
Modifier.clickable { runCatching { uri.openUri(content.url) } }
} else {
Modifier
@@ -757,8 +671,8 @@ fun DisplayBlurHash(
@Composable
fun ZoomableImageDialog(
imageUrl: ZoomableContent,
allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(),
imageUrl: BaseMediaContent,
allImages: ImmutableList<BaseMediaContent> = listOf(imageUrl).toImmutableList(),
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
@@ -821,8 +735,8 @@ fun ZoomableImageDialog(
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun DialogContent(
allImages: ImmutableList<ZoomableContent>,
imageUrl: ZoomableContent,
allImages: ImmutableList<BaseMediaContent>,
imageUrl: BaseMediaContent,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
@@ -847,14 +761,16 @@ private fun DialogContent(
SlidingCarousel(
pagerState = pagerState,
) { index ->
RenderImageOrVideo(
content = allImages[index],
roundedCorner = false,
topPaddingForControllers = Size55dp,
onControllerVisibilityChanged = { controllerVisible.value = it },
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
accountViewModel = accountViewModel,
)
allImages.getOrNull(index)?.let {
RenderImageOrVideo(
content = it,
roundedCorner = false,
topPaddingForControllers = Size55dp,
onControllerVisibilityChanged = { controllerVisible.value = it },
onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value },
accountViewModel = accountViewModel,
)
}
}
} else {
RenderImageOrVideo(
@@ -879,18 +795,19 @@ private fun DialogContent(
) {
CloseButton(onPress = onDismiss)
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
Row {
CopyToClipboard(content = myContent)
Spacer(modifier = StdHorzSpacer)
SaveToGallery(url = myContent.url)
allImages.getOrNull(pagerState.currentPage)?.let { myContent ->
if (myContent is MediaUrlContent) {
Row {
CopyToClipboard(content = myContent)
Spacer(modifier = StdHorzSpacer)
SaveToGallery(url = myContent.url)
}
} else if (myContent is MediaLocalImage && myContent.localFileExists()) {
SaveToGallery(
localFile = myContent.localFile!!,
mimeType = myContent.mimeType,
)
}
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(
localFile = myContent.localFile,
mimeType = myContent.mimeType,
)
}
}
}
@@ -935,7 +852,7 @@ fun InlineCarrousel(
}
@Composable
private fun CopyToClipboard(content: ZoomableContent) {
private fun CopyToClipboard(content: BaseMediaContent) {
val popupExpanded = remember { mutableStateOf(false) }
OutlinedButton(
@@ -955,7 +872,7 @@ private fun CopyToClipboard(content: ZoomableContent) {
@Composable
private fun ShareImageAction(
popupExpanded: MutableState<Boolean>,
content: ZoomableContent,
content: BaseMediaContent,
onDismiss: () -> Unit,
) {
DropdownMenu(
@@ -964,7 +881,7 @@ private fun ShareImageAction(
) {
val clipboardManager = LocalClipboardManager.current
if (content is ZoomableUrlContent) {
if (content is MediaUrlContent) {
DropdownMenuItem(
text = { Text(stringResource(R.string.copy_url_to_clipboard)) },
onClick = {
@@ -972,18 +889,18 @@ private fun ShareImageAction(
onDismiss()
},
)
if (content.uri != null) {
content.uri?.let {
DropdownMenuItem(
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = {
clipboardManager.setText(AnnotatedString(content.uri))
clipboardManager.setText(AnnotatedString(it))
onDismiss()
},
)
}
}
if (content is ZoomablePreloadedContent) {
if (content is MediaPreloadedContent) {
DropdownMenuItem(
text = { Text(stringResource(R.string.copy_the_note_id_to_the_clipboard)) },
onClick = {
@@ -997,7 +914,7 @@ private fun ShareImageAction(
@Composable
private fun RenderImageOrVideo(
content: ZoomableContent,
content: BaseMediaContent,
roundedCorner: Boolean,
topPaddingForControllers: Dp = Dp.Unspecified,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -1006,7 +923,7 @@ private fun RenderImageOrVideo(
) {
val automaticallyStartPlayback = remember { mutableStateOf<Boolean>(true) }
if (content is ZoomableUrlImage) {
if (content is MediaUrlImage) {
val mainModifier =
Modifier.fillMaxSize()
.zoomable(
@@ -1025,7 +942,7 @@ private fun RenderImageOrVideo(
accountViewModel,
alwayShowImage = true,
)
} else if (content is ZoomableUrlVideo) {
} else if (content is MediaUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoViewInner(
videoUri = content.url,
@@ -1038,7 +955,7 @@ private fun RenderImageOrVideo(
onControllerVisibilityChanged = onControllerVisibilityChanged,
)
}
} else if (content is ZoomableLocalImage) {
} else if (content is MediaLocalImage) {
val mainModifier =
Modifier.fillMaxSize()
.zoomable(
@@ -1057,7 +974,7 @@ private fun RenderImageOrVideo(
accountViewModel,
alwayShowImage = true,
)
} else if (content is ZoomableLocalVideo) {
} else if (content is MediaLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let {
VideoViewInner(
@@ -1076,13 +993,10 @@ private fun RenderImageOrVideo(
}
@OptIn(ExperimentalCoilApi::class)
private fun verifyHash(
content: ZoomableUrlContent,
context: Context,
): Boolean? {
private suspend fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
Amethyst.instance.coilCache.openSnapshot(content.url)?.use { snapshot ->
val hash = CryptoUtils.sha256(snapshot.data.toFile().readBytes()).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}")

View File

@@ -33,7 +33,7 @@ class CommunityFeedFilter(val note: AddressableNote, val account: Account) :
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {

View File

@@ -36,7 +36,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {

View File

@@ -36,7 +36,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {

View File

@@ -45,7 +45,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {

View File

@@ -50,7 +50,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values, true)
val notes = innerApplyFilter(LocalCache.noteListCache, true)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values, false)
return sort(notes + longFormNotes)

View File

@@ -52,7 +52,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
@@ -99,8 +99,10 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
val isAuthoredPostCited = event.findCitations().any { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex }
val isAuthorDirectlyCited = event.citedUsers().contains(authorHex)
val isAuthorOfAFork =
event.isForkFromAddressWithPubkey(authorHex) || (event.forkFromVersion()?.let { LocalCache.getNoteIfExists(it)?.author?.pubkeyHex == authorHex } == true)
return isAuthoredPostCited || isAuthorDirectlyCited
return isAuthoredPostCited || isAuthorDirectlyCited || isAuthorOfAFork
}
if (event is ReactionEvent) {

View File

@@ -36,7 +36,7 @@ class UserProfileConversationsFeedFilter(val user: User, val account: Account) :
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
return sort(innerApplyFilter(LocalCache.noteListCache))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {

View File

@@ -30,7 +30,7 @@ class UserProfileFollowersFeedFilter(val user: User, val account: Account) : Fee
}
override fun feed(): List<User> {
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
return LocalCache.userListCache.filter { it.isFollowing(user) && !account.isHidden(it) }
}
override fun limit() = 400

View File

@@ -41,7 +41,7 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) :
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val notes = innerApplyFilter(LocalCache.noteListCache)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(notes + longFormNotes)

View File

@@ -43,7 +43,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
}
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
val notes = innerApplyFilter(LocalCache.noteListCache)
return sort(notes)
}

View File

@@ -184,6 +184,7 @@ class AddBountyAmountViewModel : ViewModel() {
replyingTo = null,
root = null,
directMentions = setOf(),
forkedFrom = null,
)
nextAmount = TextFieldValue("")

View File

@@ -49,15 +49,32 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.ZapSplitSetup
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayZapSplits(
noteEvent: EventInterface,
useAuthorIfEmpty: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val list = remember(noteEvent) { noteEvent.zapSplitSetup() }
val list =
remember(noteEvent) {
val list = noteEvent.zapSplitSetup()
if (list.isEmpty() && useAuthorIfEmpty) {
listOf<ZapSplitSetup>(
ZapSplitSetup(
lnAddressOrPubKeyHex = noteEvent.pubKey(),
relay = null,
weight = 1.0,
isLnAddress = false,
),
)
} else {
list
}
}
if (list.isEmpty()) return
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@@ -77,10 +77,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -149,12 +147,9 @@ fun DisplayAccount(
) {
var baseUser by remember {
mutableStateOf<User?>(
LocalCache.getUserIfExists(
decodePublicKey(
acc.npub,
)
.toHexKey(),
),
decodePublicKeyAsHexOrNull(acc.npub)?.let {
LocalCache.getUserIfExists(it)
},
)
}
@@ -162,13 +157,8 @@ fun DisplayAccount(
LaunchedEffect(key1 = acc.npub) {
launch(Dispatchers.IO) {
baseUser =
try {
LocalCache.getOrCreateUser(
decodePublicKey(acc.npub).toHexKey(),
)
} catch (e: Exception) {
if (e is CancellationException) throw e
null
decodePublicKeyAsHexOrNull(acc.npub)?.let {
LocalCache.getOrCreateUser(it)
}
}
}

View File

@@ -23,13 +23,10 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.graphics.Rect
import android.view.View
import android.view.ViewTreeObserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -45,7 +42,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalView
import androidx.compose.ui.res.painterResource
@@ -58,8 +54,8 @@ import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size0dp
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.bottomIconModifier
import kotlinx.collections.immutable.persistentListOf
val bottomNavigationItems =
@@ -207,9 +203,7 @@ fun AddNotifIconIfNeeded(
private fun NotificationDotIcon(modifier: Modifier) {
Box(modifier.size(Size10dp)) {
Box(
modifier =
remember { Size10Modifier.clip(shape = CircleShape) }
.background(MaterialTheme.colorScheme.primary),
modifier = MaterialTheme.colorScheme.bottomIconModifier,
contentAlignment = Alignment.TopEnd,
) {
Text(
@@ -217,12 +211,7 @@ private fun NotificationDotIcon(modifier: Modifier) {
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
modifier =
remember {
Modifier
.wrapContentHeight()
.align(Alignment.TopEnd)
},
// modifier = Modifier.wrapContentHeight().align(Alignment.TopEnd),
)
}
}

View File

@@ -74,7 +74,6 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import coil.Coil
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -817,15 +816,12 @@ fun SimpleTextSpinner(
fun RenderOption(option: Name) {
when (option) {
is GeoHashName -> {
val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull()
if (geohash != null) {
LoadCityName(geohash) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
LoadCityName(option.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
}
}
}
@@ -979,11 +975,11 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.notes.filter { it.value.liveSet != null }.size +
LocalCache.noteListCache.filter { it.liveSet != null }.size +
" / " +
LocalCache.notes.filter { it.value.event != null }.size +
LocalCache.noteListCache.filter { it.event != null }.size +
" / " +
LocalCache.notes.size,
LocalCache.noteListCache.size,
)
Log.d(
"STATE DUMP",
@@ -997,21 +993,21 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.users.filter { it.value.liveSet != null }.size +
LocalCache.userListCache.filter { it.liveSet != null }.size +
" / " +
LocalCache.users.filter { it.value.info?.latestMetadata != null }.size +
LocalCache.userListCache.filter { it.info?.latestMetadata != null }.size +
" / " +
LocalCache.users.size,
LocalCache.userListCache.size,
)
Log.d(
"STATE DUMP",
"Memory used by Events: " +
LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) +
LocalCache.noteListCache.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) +
" MB",
)
LocalCache.notes.values
LocalCache.noteListCache
.groupBy { it.event?.kind() }
.forEach { Log.d("STATE DUMP", "Kind ${it.key}: \t${it.value.size} elements ") }
LocalCache.addressables.values

View File

@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
@@ -32,7 +31,6 @@ import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -240,16 +238,6 @@ sealed class Route(
}
}
// **
// * Functions below only exist because we have not broken the datasource classes into backend and
// frontend.
// **
@Composable
fun currentRoute(navController: NavHostController): String? {
val navBackStackEntry by navController.currentBackStackEntryAsState()
return navBackStackEntry?.destination?.route
}
open class LatestItem {
var newestItemPerAccount: Map<String, Note?> = mapOf()

View File

@@ -167,7 +167,7 @@ fun BadgeCompose(
tint = MaterialTheme.colorScheme.placeholderText,
)
NoteDropDownMenu(note, popupExpanded, accountViewModel)
NoteDropDownMenu(note, popupExpanded, null, accountViewModel, nav)
}
}

View File

@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -36,15 +37,28 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@Composable
@Preview
fun BlankNotePreview() {
ThemeComparisonColumn(
onDark = { BlankNote() },
onLight = { BlankNote() },
)
}
@Composable
fun BlankNote(
@@ -74,7 +88,7 @@ fun BlankNote(
}
if (!showDivider) {
Divider(
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
)
@@ -84,6 +98,32 @@ fun BlankNote(
}
}
@Composable
@Preview
fun HiddenNotePreview() {
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
ThemeComparisonColumn(
onDark = {
HiddenNote(
reports = persistentSetOf<Note>(),
isHiddenAuthor = true,
accountViewModel = accountViewModel,
nav = nav,
) {}
},
onLight = {
HiddenNote(
reports = persistentSetOf<Note>(),
isHiddenAuthor = true,
accountViewModel = accountViewModel,
nav = nav,
) {}
},
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
@@ -148,3 +188,56 @@ fun HiddenNote(
)
}
}
@Preview
@Composable
fun HiddenNoteByMePreview() {
ThemeComparisonColumn(
onDark = { HiddenNoteByMe {} },
onLight = { HiddenNoteByMe {} },
)
}
@Composable
fun HiddenNoteByMe(
modifier: Modifier = Modifier,
isQuote: Boolean = false,
onClick: () -> Unit,
) {
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Row(
modifier = Modifier.padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(30.dp),
) {
Text(
text = stringResource(R.string.post_was_hidden),
color = Color.Gray,
)
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ButtonPadding,
) {
Text(text = stringResource(R.string.show_anyway), color = Color.White)
}
}
}
if (!isQuote) {
Divider(
thickness = DividerThickness,
)
}
}
}

View File

@@ -787,7 +787,6 @@ private fun DisplayMessageUsername(
Spacer(modifier = StdHorzSpacer)
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "",
maxLines = 1,
tags = userTags,
fontWeight = FontWeight.Bold,

View File

@@ -129,7 +129,7 @@ fun MessageSetCompose(
nav = nav,
)
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
}
}

View File

@@ -0,0 +1,161 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "resourceType",
)
@JsonSubTypes(
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
)
open class Resource(
var resourceType: String? = null,
var id: String = "",
)
class Practitioner(
resourceType: String? = null,
id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource(resourceType, id)
class Patient(
resourceType: String? = null,
id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource(resourceType, id)
class HumanName(
var use: String? = null,
var family: String? = null,
var given: ArrayList<String> = arrayListOf(),
) {
fun assembleName(): String {
return given.joinToString(" ") + " " + family
}
}
class Bundle(
resourceType: String? = null,
id: String = "",
var type: String? = null,
var created: String? = null,
var entry: List<Resource> = arrayListOf(),
) : Resource(resourceType, id)
class VisionPrescription(
resourceType: String? = null,
id: String = "",
var status: String? = null,
var created: String? = null,
var patient: Reference? = Reference(),
var encounter: Reference? = Reference(),
var dateWritten: String? = null,
var prescriber: Reference? = Reference(),
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id)
class LensSpecification(
var eye: String? = null,
var sphere: Double? = null,
var cylinder: Double? = null,
var axis: Double? = null,
var add: Double? = null,
var prism: List<Prism> = arrayListOf(),
// contact lenses
var power: Double? = null,
var backCurve: Double? = null,
var diameter: Double? = null,
var color: String? = null,
var brand: String? = null,
var note: String? = null,
)
class Prism(
var amount: Double? = null,
var base: String? = null,
)
class Reference(
var reference: String? = null,
)
data class FhirElementDatabase(
var baseResource: Resource? = null,
var localDb: ImmutableMap<String, Resource> = persistentMapOf(),
)
fun findReferenceInDb(
it: String,
db: Map<String, Resource>,
): Resource? {
val parts = it.split("/")
return if (parts.size == 2) {
db.get(parts[1].removePrefix("#"))
} else {
db.get(it.removePrefix("#"))
}
}
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
return try {
val resource = mapper.readValue(json, Resource::class.java)
val db =
when (resource) {
is Bundle -> {
resource.entry.associateBy { it.id }.toImmutableMap()
}
else -> {
persistentMapOf(resource.id to resource)
}
}
FhirElementDatabase(
localDb = db,
baseResource = resource,
)
} catch (e: Exception) {
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
null
}
}

View File

@@ -65,7 +65,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -75,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller
import com.vitorpamplona.amethyst.ui.theme.Size10dp
@@ -90,10 +90,10 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.EmptyTagList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
@@ -159,7 +159,7 @@ fun MultiSetCompose(
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = remember { Modifier.padding(top = 5.dp) },
modifier = HalfTopPadding,
isBoostedNote = true,
showHidden = showHidden,
parentBackgroundColor = backgroundColor,
@@ -167,7 +167,7 @@ fun MultiSetCompose(
nav = nav,
)
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
NoteDropDownMenu(baseNote, popupExpanded, null, accountViewModel, nav)
}
Divider(
@@ -232,10 +232,7 @@ fun RenderLikeGallery(
val url = noStartColon.substringAfter(":")
val renderable =
listOf(
ImageUrlType(url),
)
.toImmutableList()
persistentListOf(Nip30CustomEmoji.ImageUrlType(url))
InLineIconRenderer(
renderable,

View File

@@ -342,11 +342,9 @@ private fun RenderMainPopup(
icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.broadcast),
) {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss()
}
VerticalDivider(primaryLight)
NoteQuickActionItem(

View File

@@ -68,7 +68,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -101,7 +100,7 @@ fun PollNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel")
val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel${baseNote.idHex}")
pollViewModel.load(accountViewModel.account, baseNote)
@@ -126,11 +125,9 @@ fun PollNote(
) {
WatchZapsAndUpdateTallies(baseNote, pollViewModel)
val tallies by pollViewModel.tallies.collectAsStateWithLifecycle()
tallies.forEach { poll_op ->
pollViewModel.tallies.forEach { option ->
OptionNote(
poll_op,
option,
pollViewModel,
baseNote,
accountViewModel,
@@ -167,9 +164,9 @@ private fun OptionNote(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp),
) {
if (!pollViewModel.canZap()) {
if (!pollViewModel.canZap.value) {
val color =
if (poolOption.consensusThreadhold) {
if (poolOption.consensusThreadhold.value) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colorScheme.mediumImportanceLink
@@ -181,8 +178,7 @@ private fun OptionNote(
pollViewModel = pollViewModel,
nonClickablePrepend = {
RenderOptionAfterVote(
poolOption.descriptor,
poolOption.tally.toFloat(),
poolOption,
color,
canPreview,
tags,
@@ -220,8 +216,7 @@ private fun OptionNote(
@Composable
private fun RenderOptionAfterVote(
description: String,
totalRatio: Float,
poolOption: PollOption,
color: Color,
canPreview: Boolean,
tags: ImmutableListOfLists<String>,
@@ -229,10 +224,9 @@ private fun RenderOptionAfterVote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val totalPercentage = remember(totalRatio) { "${(totalRatio * 100).roundToInt()}%" }
Box(
Modifier.fillMaxWidth(0.75f)
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.border(
2.dp,
@@ -243,7 +237,9 @@ private fun RenderOptionAfterVote(
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = totalRatio,
progress = {
poolOption.tally.value.toFloat()
},
)
Row(
@@ -251,21 +247,31 @@ private fun RenderOptionAfterVote(
) {
Column(
horizontalAlignment = Alignment.End,
modifier = remember { Modifier.padding(horizontal = 10.dp).width(40.dp) },
modifier =
remember {
Modifier
.padding(horizontal = 10.dp)
.width(45.dp)
},
) {
Text(
text = totalPercentage,
text = "${(poolOption.tally.value.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold,
)
}
Column(
modifier = remember { Modifier.fillMaxWidth().padding(15.dp) },
modifier =
remember {
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 10.dp)
},
) {
TranslatableRichTextViewer(
description,
poolOption.descriptor,
canPreview,
remember { Modifier },
Modifier,
tags,
backgroundColor,
accountViewModel,
@@ -286,7 +292,8 @@ private fun RenderOptionBeforeVote(
nav: (String) -> Unit,
) {
Box(
Modifier.fillMaxWidth(0.75f)
Modifier
.fillMaxWidth(0.75f)
.clip(shape = QuoteBorder)
.border(
2.dp,
@@ -358,7 +365,7 @@ fun ZapVote(
R.string.poll_unable_to_vote,
R.string.poll_author_no_vote,
)
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn.value) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
accountViewModel.toast(
R.string.poll_unable_to_vote,
@@ -443,7 +450,7 @@ fun ZapVote(
clickablePrepend()
if (poolOption.zappedByLoggedIn) {
if (poolOption.zappedByLoggedIn.value) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -471,8 +478,8 @@ fun ZapVote(
}
// only show tallies after a user has zapped note
if (!pollViewModel.canZap()) {
val amountStr = remember(poolOption.zappedValue) { showAmount(poolOption.zappedValue) }
if (!pollViewModel.canZap.value) {
val amountStr = remember(poolOption.zappedValue.value) { showAmount(poolOption.zappedValue.value) }
Text(
text = amountStr,
fontSize = Font14SP,

View File

@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
@@ -36,20 +37,18 @@ import com.vitorpamplona.quartz.events.VALUE_MAXIMUM
import com.vitorpamplona.quartz.events.VALUE_MINIMUM
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.math.RoundingMode
@Immutable
@Stable
data class PollOption(
val option: Int,
val descriptor: String,
val zappedValue: BigDecimal,
val tally: BigDecimal,
val consensusThreadhold: Boolean,
val zappedByLoggedIn: Boolean,
var zappedValue: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
var tally: MutableState<BigDecimal> = mutableStateOf(BigDecimal.ZERO),
var consensusThreadhold: MutableState<Boolean> = mutableStateOf(false),
var zappedByLoggedIn: MutableState<Boolean> = mutableStateOf(false),
)
@Stable
@@ -70,65 +69,67 @@ class PollNoteViewModel : ViewModel() {
private var totalZapped: BigDecimal = BigDecimal.ZERO
private var wasZappedByLoggedInAccount: Boolean = false
private val _tallies = MutableStateFlow<List<PollOption>>(emptyList())
val tallies = _tallies.asStateFlow()
var canZap = mutableStateOf(false)
var tallies: List<PollOption> = emptyList()
fun load(
acc: Account,
note: Note?,
) {
account = acc
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
consensusThreshold =
pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagLong(CLOSED_AT)
if (acc != account || pollNote != note) {
account = acc
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
consensusThreshold =
pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagLong(CLOSED_AT)
totalZapped = BigDecimal.ZERO
wasZappedByLoggedInAccount = false
canZap.value = checkIfCanZap()
tallies = pollOptions?.keys?.map { option ->
PollOption(
option,
pollOptions?.get(option) ?: "",
)
} ?: emptyList()
}
}
fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
account?.calculateIfNoteWasZappedByAccount(pollNote) { wasZappedByLoggedInAccount = true }
account?.calculateIfNoteWasZappedByAccount(pollNote) {
wasZappedByLoggedInAccount = true
canZap.value = checkIfCanZap()
}
val newOptions =
pollOptions?.keys?.map { option ->
val zappedInOption = zappedPollOptionAmount(option)
tallies.forEach {
val zappedValue = zappedPollOptionAmount(it.option)
val tallyValue =
if (totalZapped > BigDecimal.ZERO) {
zappedValue.divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
val myTally =
if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
val cachedZappedByLoggedIn =
account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(
option,
pollOptions?.get(option) ?: "",
zappedInOption,
myTally,
consensus,
cachedZappedByLoggedIn,
)
}
_tallies.emit(
newOptions ?: emptyList(),
)
it.zappedValue.value = zappedValue
it.tally.value = tallyValue
it.consensusThreadhold.value = consensusThreshold != null && tallyValue >= consensusThreshold!!
it.zappedByLoggedIn.value = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(it.option, it1) } ?: false
}
}
}
fun canZap(): Boolean {
fun checkIfCanZap(): Boolean {
val account = account ?: return false
val note = pollNote ?: return false
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
@@ -209,7 +210,7 @@ class PollNoteViewModel : ViewModel() {
return false
}
fun isPollOptionZappedBy(
suspend fun isPollOptionZappedBy(
option: Int,
user: User,
onWasZappedByAuthor: () -> Unit,

View File

@@ -61,6 +61,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -99,9 +100,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -128,6 +128,8 @@ import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
@@ -145,6 +147,7 @@ import kotlin.math.roundToInt
fun ReactionsRow(
baseNote: Note,
showReactionDetail: Boolean,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -152,7 +155,7 @@ fun ReactionsRow(
Spacer(modifier = HalfDoubleVertSpacer)
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, editState, accountViewModel, nav)
Spacer(modifier = HalfDoubleVertSpacer)
@@ -169,6 +172,7 @@ private fun InnerReactionRow(
baseNote: Note,
showReactionDetail: Boolean,
wantsToSeeReactions: MutableState<Boolean>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
@@ -190,6 +194,7 @@ private fun InnerReactionRow(
three = {
BoostWithDialog(
baseNote,
editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
@@ -495,23 +500,54 @@ private fun WatchZapAndRenderGallery(
@Composable
private fun BoostWithDialog(
baseNote: Note,
editState: State<GenericLoadable<EditState>>,
grayTint: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var wantsToQuote by remember { mutableStateOf<Note?>(null) }
var wantsToFork by remember { mutableStateOf<Note?>(null) }
if (wantsToQuote != null) {
NewPostView(
onClose = { wantsToQuote = null },
baseReplyTo = null,
quote = wantsToQuote,
version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
accountViewModel = accountViewModel,
nav = nav,
)
}
BoostReaction(baseNote, grayTint, accountViewModel) { wantsToQuote = baseNote }
if (wantsToFork != null) {
val replyTo =
remember(wantsToFork) {
val forkEvent = wantsToFork?.event
if (forkEvent is BaseTextNoteEvent) {
val hex = forkEvent.replyingTo()
wantsToFork?.replyTo?.filter { it.event?.id() == hex }?.firstOrNull()
} else {
null
}
}
NewPostView(
onClose = { wantsToFork = null },
baseReplyTo = replyTo,
fork = wantsToFork,
version = (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value,
accountViewModel = accountViewModel,
nav = nav,
)
}
BoostReaction(
baseNote,
grayTint,
accountViewModel,
onQuotePress = { wantsToQuote = baseNote },
onForkPress = { wantsToFork = baseNote },
)
}
@Composable
@@ -625,7 +661,7 @@ fun TextCount(
}
@Composable
private fun SlidingAnimationAmount(
fun SlidingAnimationAmount(
amount: MutableState<String>,
textColor: Color,
) {
@@ -651,6 +687,7 @@ fun BoostReaction(
iconSizeModifier: Modifier = Size20Modifier,
iconSize: Dp = Size20dp,
onQuotePress: () -> Unit,
onForkPress: () -> Unit,
) {
var wantsToBoost by remember { mutableStateOf(false) }
@@ -672,7 +709,13 @@ fun BoostReaction(
wantsToBoost = false
onQuotePress()
},
onRepost = { accountViewModel.boost(baseNote) },
onRepost = {
accountViewModel.boost(baseNote)
},
onFork = {
wantsToBoost = false
onForkPress()
},
)
}
}
@@ -808,10 +851,9 @@ private fun RenderReactionType(
if (reactionType.isNotEmpty() && reactionType[0] == ':') {
val renderable =
remember(reactionType) {
listOf(
ImageUrlType(reactionType.removePrefix(":").substringAfter(":")),
persistentListOf(
Nip30CustomEmoji.ImageUrlType(reactionType.removePrefix(":").substringAfter(":")),
)
.toImmutableList()
}
InLineIconRenderer(
@@ -1028,7 +1070,7 @@ fun ZapReaction(
}
}
private fun zapClick(
fun zapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
context: Context,
@@ -1065,7 +1107,7 @@ private fun zapClick(
}
@Composable
private fun ObserveZapIcon(
fun ObserveZapIcon(
baseNote: Note,
accountViewModel: AccountViewModel,
inner: @Composable (MutableState<Boolean>) -> Unit,
@@ -1088,7 +1130,7 @@ private fun ObserveZapIcon(
}
@Composable
private fun ObserveZapAmountText(
fun ObserveZapAmountText(
baseNote: Note,
accountViewModel: AccountViewModel,
inner: @Composable (MutableState<String>) -> Unit,
@@ -1151,6 +1193,7 @@ private fun BoostTypeChoicePopup(
onDismiss: () -> Unit,
onQuote: () -> Unit,
onRepost: () -> Unit,
onFork: () -> Unit,
) {
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
@@ -1191,6 +1234,18 @@ private fun BoostTypeChoicePopup(
) {
Text(stringResource(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onFork,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringResource(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
}
}
}
}
@@ -1284,11 +1339,10 @@ private fun ActionableReactionButton(
val url = noStartColon.substringAfter(":")
val renderable =
listOf(
ImageUrlType(url),
TextType(removeSymbol),
persistentListOf(
Nip30CustomEmoji.ImageUrlType(url),
Nip30CustomEmoji.TextType(removeSymbol),
)
.toImmutableList()
InLineIconRenderer(
renderable,

View File

@@ -20,11 +20,19 @@
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
@@ -37,14 +45,17 @@ 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.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconButtonModifier
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonIconModifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@OptIn(ExperimentalLayoutApi::class)
@@ -58,16 +69,18 @@ fun RelayBadges(
val relayList by baseNote.live().relayInfo.observeAsState(baseNote.relays)
Spacer(DoubleVertSpacer)
Spacer(StdVertSpacer)
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow {
if (expanded) {
relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
} else {
relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
FlowRow(modifier = Modifier.fillMaxWidth()) {
if (expanded) {
relayList?.forEach { RenderRelay(it, accountViewModel, nav) }
} else {
relayList?.getOrNull(0)?.let { RenderRelay(it, accountViewModel, nav) }
relayList?.getOrNull(1)?.let { RenderRelay(it, accountViewModel, nav) }
relayList?.getOrNull(2)?.let { RenderRelay(it, accountViewModel, nav) }
}
}
}
@@ -76,6 +89,62 @@ fun RelayBadges(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Preview
@Composable
fun RelayIconLayoutPreview() {
Column(modifier = Modifier.width(55.dp)) {
Spacer(StdVertSpacer)
// FlowRow Seems to be a lot faster than LazyVerticalGrid
Box(modifier = Modifier.fillMaxWidth().padding(start = 2.dp, end = 1.dp)) {
FlowRow {
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(
Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black),
)
}
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
}
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
}
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
}
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
}
Box(
modifier = Modifier.size(17.dp),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(13.dp).clip(shape = CircleShape).background(Color.Black))
}
}
}
ShowMoreRelaysButton { }
}
}
@Composable
private fun ShowMoreRelaysButton(onClick: () -> Unit) {
Row(
@@ -84,13 +153,11 @@ private fun ShowMoreRelaysButton(onClick: () -> Unit) {
verticalAlignment = Alignment.Top,
) {
IconButton(
modifier = ShowMoreRelaysButtonIconButtonModifier,
onClick = onClick,
) {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = stringResource(id = R.string.expand_relay_list),
modifier = ShowMoreRelaysButtonIconModifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}

View File

@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
@@ -39,6 +38,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -46,19 +46,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInformationDialog
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size15dp
import com.vitorpamplona.amethyst.ui.theme.Size17dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.relayIconModifier
@@ -132,11 +131,27 @@ fun RenderRelay(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInformation? by remember { mutableStateOf(null) }
val relayInfo by
produceState(
initialValue = Nip11CachedRetriever.getFromCache(relay.url),
) {
if (value == null) {
accountViewModel.retrieveRelayDocument(
relay.url,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
},
)
}
}
if (relayInfo != null) {
var openRelayDialog by remember { mutableStateOf(false) }
if (openRelayDialog && relayInfo != null) {
RelayInformationDialog(
onClose = { relayInfo = null },
onClose = { openRelayDialog = false },
relayInfo = relayInfo!!,
relayBriefInfo = relay,
accountViewModel = accountViewModel,
@@ -147,18 +162,12 @@ fun RenderRelay(
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
val ripple = rememberRipple(bounded = false, radius = Size15dp)
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
val ripple = rememberRipple(bounded = false, radius = Size17dp)
val clickableModifier =
remember(relay) {
Modifier
.padding(1.dp)
.size(Size15dp)
.size(Size17dp)
.clickable(
role = Role.Button,
interactionSource = interactionSource,
@@ -166,7 +175,9 @@ fun RenderRelay(
onClick = {
accountViewModel.retrieveRelayDocument(
relay.url,
onInfo = { relayInfo = it },
onInfo = {
openRelayDialog = true
},
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
@@ -213,14 +224,18 @@ fun RenderRelay(
modifier = clickableModifier,
contentAlignment = Alignment.Center,
) {
RenderRelayIcon(relay.displayUrl, relay.favIcon, automaticallyShowProfilePicture)
RenderRelayIcon(
displayUrl = relay.displayUrl,
iconUrl = relayInfo?.icon ?: relay.favIcon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
)
}
}
@Composable
fun RenderRelayIcon(
displayUrl: String,
iconUrl: String,
iconUrl: String?,
loadProfilePicture: Boolean,
iconModifier: Modifier = MaterialTheme.colorScheme.relayIconModifier,
) {

View File

@@ -71,6 +71,46 @@ fun timeAgo(
}
}
fun timeAgoNoDot(
time: Long?,
context: Context,
): String {
if (time == null) return " "
if (time == 0L) return " ${context.getString(R.string.never)}"
val timeDifference = TimeUtils.now() - time
return if (timeDifference > TimeUtils.ONE_YEAR) {
// Dec 12, 2022
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale)
monthFormatter = SimpleDateFormat("MMM dd", locale)
}
yearFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_MONTH) {
// Dec 12
if (locale != Locale.getDefault()) {
locale = Locale.getDefault()
yearFormatter = SimpleDateFormat("MMM dd, yyyy", locale)
monthFormatter = SimpleDateFormat("MMM dd", locale)
}
monthFormatter.format(time * 1000)
} else if (timeDifference > TimeUtils.ONE_DAY) {
// 2 days
(timeDifference / TimeUtils.ONE_DAY).toString() + context.getString(R.string.d)
} else if (timeDifference > TimeUtils.ONE_HOUR) {
(timeDifference / TimeUtils.ONE_HOUR).toString() + context.getString(R.string.h)
} else if (timeDifference > TimeUtils.ONE_MINUTE) {
(timeDifference / TimeUtils.ONE_MINUTE).toString() + context.getString(R.string.m)
} else {
context.getString(R.string.now)
}
}
fun timeAgoShort(
mills: Long?,
stringForNow: String,

View File

@@ -80,17 +80,17 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.firstFullChar
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Nip30CustomEmoji
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -283,11 +283,10 @@ private fun RenderReactionOption(
val url = noStartColon.substringAfter(":")
val renderable =
listOf(
ImageUrlType(url),
TextType(""),
persistentListOf(
Nip30CustomEmoji.ImageUrlType(url),
Nip30CustomEmoji.TextType(""),
)
.toImmutableList()
InLineIconRenderer(
renderable,

View File

@@ -87,7 +87,6 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
@@ -99,6 +98,8 @@ import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
import com.vitorpamplona.quartz.encoders.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -165,18 +166,11 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
addedWSS
}
val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null }
val privKeyHex =
try {
unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() }
} catch (e: Exception) {
if (e is CancellationException) throw e
null
}
val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) }
if (pubkeyHex != null) {
account?.changeZapPaymentRequest(
Nip47URI(
Nip47WalletConnect.Nip47URI(
pubkeyHex,
relayUrl,
privKeyHex,
@@ -207,7 +201,7 @@ class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
}
fun updateNIP47(uri: String) {
val contact = Nip47WalletConnectParser.parse(uri)
val contact = Nip47WalletConnect.parse(uri)
if (contact != null) {
walletConnectPubkey = TextFieldValue(contact.pubKeyHex)
walletConnectRelay = TextFieldValue(contact.relayUri ?: "")

View File

@@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -66,11 +67,14 @@ import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -454,7 +458,9 @@ data class DropDownParams(
fun NoteDropDownMenu(
note: Note,
popupExpanded: MutableState<Boolean>,
editState: State<GenericLoadable<EditState>>? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var reportDialogShowing by remember { mutableStateOf(false) }
@@ -473,6 +479,30 @@ fun NoteDropDownMenu(
val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } }
val wantsToEditPost =
remember {
mutableStateOf(false)
}
if (wantsToEditPost.value) {
// avoids changing while drafting a note and a new event shows up.
val versionLookingAt =
remember {
(editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value
}
EditPostView(
onClose = {
popupExpanded.value = false
wantsToEditPost.value = false
},
edit = note,
versionLookingAt = versionLookingAt,
accountViewModel = accountViewModel,
nav = nav,
)
}
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss,
@@ -551,16 +581,48 @@ fun NoteDropDownMenu(
},
)
Divider()
if (note.event is TextNoteEvent) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_post)) },
onClick = {
wantsToEditPost.value = true
},
)
} else {
DropdownMenuItem(
text = { Text(stringResource(R.string.propose_an_edit)) },
onClick = {
wantsToEditPost.value = true
},
)
}
}
DropdownMenuItem(
text = { Text(stringResource(R.string.broadcast)) },
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.broadcast(note)
onDismiss()
}
accountViewModel.broadcast(note)
onDismiss()
},
)
Divider()
if (accountViewModel.account.hasPendingAttestations(note)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.timestamp_pending)) },
onClick = {
onDismiss()
},
)
} else {
DropdownMenuItem(
text = { Text(stringResource(R.string.timestamp_it)) },
onClick = {
accountViewModel.timestamp(note)
onDismiss()
},
)
}
Divider()
if (state.isPrivateBookmarkNote) {
DropdownMenuItem(
text = { Text(stringResource(R.string.remove_from_private_bookmarks)) },

View File

@@ -232,7 +232,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
val replies = mutableMapOf<String, Int>()
val takenIntoAccount = mutableSetOf<HexKey>()
LocalCache.notes.values.forEach {
LocalCache.noteListCache.forEach {
val noteEvent = it.event
if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) {
if (noteEvent is ReactionEvent) {

View File

@@ -0,0 +1,459 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Arrangement
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
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig
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.ThemeType
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.events.Event
import fr.acinq.secp256k1.Hex
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@Preview
@Composable
fun ZapTheDevsCardPreview() {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
val myCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
sharedPreferencesViewModel.init()
sharedPreferencesViewModel.updateTheme(ThemeType.DARK)
runBlocking(Dispatchers.IO) {
val releaseNotes =
"""
{
"id": "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2",
"pubkey": "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
"created_at": 1708014675,
"kind": 1,
"tags": [
[
"p",
"ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b",
"",
"mention"
],
[
"p",
"7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf",
"",
"mention"
],
[
"t",
"Amethyst"
],
[
"t",
"amethyst"
],
[
"zap",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
"wss://vitor.nostr1.com",
"0.6499999761581421"
],
[
"zap",
"ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b",
"wss://nos.lol",
"0.25"
],
[
"zap",
"7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf",
"wss://vitor.nostr1.com",
"0.10000000149011612"
],
[
"r",
"https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk"
],
[
"r",
"https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk"
]
],
"content": "#Amethyst v0.84.2: Text alignment fix\n\nBugfixes:\n- Fixes link misalignment in posts\n\nUpdated translations: \n- Czech, German, Swedish, and Portuguese by nostr:npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef\n- French by nostr:npub106efcyntxc5qwl3w8krrhyt626m59ya2nk9f40px5s968u5xdwhsjsr8fz\n\nDownload:\n- [Play Edition](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-googleplay-universal-v0.84.2.apk )\n- [FOSS Edition - No translations](https://github.com/vitorpamplona/amethyst/releases/download/v0.84.2/amethyst-fdroid-universal-v0.84.2.apk )",
"sig": "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed"
}
""".trimIndent()
LocalCache.justConsume(Event.fromJson(releaseNotes), null)
}
val accountViewModel =
AccountViewModel(
Account(
// blank keys
keyPair =
KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
forcePubKeyCheck = false,
),
scope = myCoroutineScope,
),
sharedPreferencesViewModel.sharedPrefs,
)
LoadNote(
baseNoteHex = "0465b20da0adf45dd612024d124e1ed384f7ecd2cd7358e77998828e7bf35fa2",
accountViewModel,
) { releaseNote ->
if (releaseNote != null) {
ThemeComparisonColumn(
onDark = {
ZapTheDevsCard(
releaseNote,
accountViewModel,
nav = {},
)
},
onLight = {
ZapTheDevsCard(
releaseNote,
accountViewModel,
nav = {},
)
},
)
}
}
}
@Composable
fun ZapTheDevsCard(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val releaseNoteState by baseNote.live().metadata.observeAsState()
val releaseNote = releaseNoteState?.note ?: return
Row(modifier = Modifier.padding(horizontal = Size10dp)) {
Card(
modifier = MaterialTheme.colorScheme.imageModifier,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Title
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(id = R.string.zap_the_devs_title),
style =
TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
IconButton(
modifier = Size20Modifier,
onClick = { accountViewModel.markDonatedInThisVersion() },
) {
CloseIcon()
}
}
Spacer(modifier = StdVertSpacer)
ClickableText(
text =
buildAnnotatedString {
append(stringResource(id = R.string.zap_the_devs_description, BuildConfig.VERSION_NAME))
append(" ")
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append("#value4value")
}
},
onClick = { nav("Hashtag/value4value") },
)
Spacer(modifier = StdVertSpacer)
val noteEvent = releaseNote.event
if (noteEvent != null) {
val route =
remember(releaseNote) {
routeFor(releaseNote, accountViewModel.userProfile())
}
if (route != null) {
ClickableText(
text =
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.version_name, BuildConfig.VERSION_NAME.substringBefore("-")))
}
append(" " + stringResource(id = R.string.brought_to_you_by))
},
onClick = { nav(route) },
)
} else {
Text(stringResource(id = R.string.this_version_brought_to_you_by))
}
Spacer(modifier = StdVertSpacer)
DisplayZapSplits(
noteEvent = noteEvent,
useAuthorIfEmpty = true,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
ZapDonationButton(
baseNote = releaseNote,
grayTint = MaterialTheme.colorScheme.onPrimary,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
fun ZapDonationButton(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
iconSize: Dp = Size20dp,
iconSizeModifier: Modifier = Size20Modifier,
animationSize: Dp = 14.dp,
nav: (String) -> Unit,
) {
var wantsToZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var wantsToPay by
remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(
persistentListOf(),
)
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var hasZapped by remember { mutableStateOf(false) }
Button(
onClick = {
zapClick(
baseNote,
accountViewModel,
context,
onZappingProgress = { progress: Float ->
scope.launch { zappingProgress = progress }
},
onMultipleChoices = { wantsToZap = true },
onError = { _, message ->
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = message
}
},
onPayViaIntent = { wantsToPay = it },
)
},
modifier = Modifier.fillMaxWidth(),
) {
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote = baseNote,
iconSize = iconSize,
accountViewModel = accountViewModel,
onDismiss = {
wantsToZap = false
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = false
},
onError = { _, message ->
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = message
}
},
onProgress = {
scope.launch(Dispatchers.Main) { zappingProgress = it }
},
onPayViaIntent = { wantsToPay = it },
)
}
if (showErrorMessageDialog != null) {
ErrorMessageDialog(
title = stringResource(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
baseNote.author?.let {
scope.launch(Dispatchers.IO) {
val route = routeToMessage(it, showErrorMessageDialog, accountViewModel)
nav(route)
}
}
},
onDismiss = { showErrorMessageDialog = null },
)
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = { wantsToPay = persistentListOf() },
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = iconSizeModifier,
) {
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(ModifierWidth3dp)
CircularProgressIndicator(
progress =
animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
)
.value,
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
color = grayTint,
)
} else {
ObserveZapIcon(
baseNote,
accountViewModel,
) { wasZappedByLoggedInUser ->
LaunchedEffect(wasZappedByLoggedInUser.value) {
hasZapped = wasZappedByLoggedInUser.value
if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) {
delay(1000)
accountViewModel.markDonatedInThisVersion()
}
}
Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
ZapIcon(iconSizeModifier, grayTint)
}
}
}
}
}
if (hasZapped) {
Text(text = stringResource(id = R.string.thank_you))
} else {
Text(text = stringResource(id = R.string.donate_now))
}
}
}

View File

@@ -29,28 +29,14 @@ import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.CancellationException
@Composable
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
SimpleQrCodeScanner {
try {
val nip19 = Nip19.uriToRoute(it)
val startingPage =
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
if (startingPage != null) {
onScan(startingPage)
} else {
onScan(null)
}
onScan(uriToRoute(it))
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.e("NIP19 Scanner", "Error parsing $it", e)

View File

@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
@@ -85,8 +85,20 @@ class AccountStateViewModel() : ViewModel() {
loginWithExternalSigner: Boolean = false,
packageName: String = "",
) = withContext(Dispatchers.IO) {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
val parsed = Nip19Bech32.uriToRoute(key)?.entity
val pubKeyParsed =
when (parsed) {
is Nip19Bech32.NSec -> null
is Nip19Bech32.NPub -> parsed.hex.hexToByteArray()
is Nip19Bech32.NProfile -> parsed.hex.hexToByteArray()
is Nip19Bech32.Note -> null
is Nip19Bech32.NEvent -> null
is Nip19Bech32.NEmbed -> null
is Nip19Bech32.NRelay -> null
is Nip19Bech32.NAddress -> null
else -> null
}
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
if (loginWithExternalSigner && pubKeyParsed == null) {

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -45,10 +46,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapTheDevsCard
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -176,6 +180,10 @@ private fun FeedLoaded(
contentPadding = FeedPadding,
state = listState,
) {
item {
ShowDonationCard(accountViewModel, nav)
}
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
val defaultModifier = remember { Modifier.fillMaxWidth().animateItemPlacement() }
@@ -192,6 +200,28 @@ private fun FeedLoaded(
}
}
@Composable
private fun ShowDonationCard(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val account by accountViewModel.account.live.observeAsState()
if (account?.account?.hasDonatedInThisVersion() == false) {
LoadNote(
BuildConfig.RELEASE_NOTES_ID,
accountViewModel,
) { loadedNoteId ->
if (loadedNoteId != null) {
ZapTheDevsCard(
loadedNoteId,
accountViewModel,
nav,
)
}
}
}
}
@Composable
private fun RenderCardItem(
item: Card,

View File

@@ -179,7 +179,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val event = (zapEvent.event as LnZapEvent)
val author =
event.zappedAuthor().firstNotNullOfOrNull {
LocalCache.users[it] // don't create user if it doesn't exist
LocalCache.getUserIfExists(it) // don't create user if it doesn't exist
}
if (author != null) {
val zapRequest = author.zaps.filter { it.value == zapEvent }.keys.firstOrNull()

View File

@@ -54,7 +54,7 @@ fun RefreshingChatroomFeedView(
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
RefresheableView(viewModel, enablePullRefresh) {
RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderChatroomFeedView(
viewModel,

View File

@@ -45,7 +45,7 @@ fun ChatroomListFeedView(
nav: (String) -> Unit,
markAsRead: MutableState<Boolean>,
) {
RefresheableView(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
RefresheableBox(viewModel, true) { CrossFadeState(viewModel, accountViewModel, nav, markAsRead) }
}
@Composable

View File

@@ -27,8 +27,10 @@ import kotlinx.collections.immutable.ImmutableList
sealed class FeedState {
object Loading : FeedState()
class Loaded(val feed: MutableState<ImmutableList<Note>>, val showHidden: MutableState<Boolean>) :
FeedState()
class Loaded(
val feed: MutableState<ImmutableList<Note>>,
val showHidden: MutableState<Boolean>,
) : FeedState()
object Empty : FeedState()

View File

@@ -27,6 +27,7 @@ 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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -56,6 +57,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlin.time.ExperimentalTime
@Composable
@@ -67,13 +69,15 @@ fun RefresheableFeedView(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
RefresheableView(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, routeForLastRead, scrollStateKey, accountViewModel, nav)
RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
}
}
@Composable
fun RefresheableView(
fun RefresheableBox(
viewModel: InvalidatableViewModel,
enablePullRefresh: Boolean = true,
content: @Composable () -> Unit,
@@ -108,19 +112,6 @@ fun RefresheableView(
}
}
@Composable
private fun SaveableFeedState(
viewModel: FeedViewModel,
routeForLastRead: String?,
scrollStateKey: String? = null,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
}
@Composable
fun SaveableFeedState(
viewModel: FeedViewModel,
@@ -158,12 +149,16 @@ fun SaveableGridFeedState(
}
@Composable
private fun RenderFeed(
fun RenderFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String?,
onLoaded: @Composable (FeedState.Loaded) -> Unit = { FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav) },
onEmpty: @Composable () -> Unit = { FeedEmpty { viewModel.invalidateData() } },
onError: @Composable (String) -> Unit = { FeedError(it) { viewModel.invalidateData() } },
onLoading: @Composable () -> Unit = { LoadingFeed() },
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
@@ -172,24 +167,10 @@ private fun RenderFeed(
animationSpec = tween(durationMillis = 100),
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty { viewModel.invalidateData() }
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is FeedState.Loaded -> {
FeedLoaded(
state = state,
listState = listState,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
)
}
is FeedState.Loading -> {
LoadingFeed()
}
is FeedState.Empty -> onEmpty()
is FeedState.FeedError -> onError(state.errorMessage)
is FeedState.Loaded -> onLoaded(state)
is FeedState.Loading -> onLoading()
}
}
}
@@ -277,6 +258,7 @@ fun FeedError(
verticalArrangement = Arrangement.Center,
) {
Text("${stringResource(R.string.error_loading_replies)} $errorMessage")
Spacer(modifier = StdVertSpacer)
Button(
modifier = Modifier.align(Alignment.CenterHorizontally),
onClick = onRefresh,
@@ -294,6 +276,7 @@ fun FeedEmpty(onRefresh: () -> Unit) {
verticalArrangement = Arrangement.Center,
) {
Text(stringResource(R.string.feed_is_empty))
Spacer(modifier = StdVertSpacer)
OutlinedButton(onClick = onRefresh) { Text(text = stringResource(R.string.refresh)) }
}
}

View File

@@ -328,7 +328,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) :
_feedContent.update { FeedState.Empty }
} else if (currentState is FeedState.Loaded) {
// updates the current list
currentState.showHidden.value = localFilter.showHiddenKey()
if (currentState.showHidden.value != localFilter.showHiddenKey()) {
currentState.showHidden.value = localFilter.showHiddenKey()
}
currentState.feed.value = notes
} else {
_feedContent.update {

View File

@@ -45,7 +45,7 @@ fun RefreshingFeedStringFeedView(
enablePullRefresh: Boolean = true,
inner: @Composable (String) -> Unit,
) {
RefresheableView(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
RefresheableBox(viewModel, enablePullRefresh) { StringFeedView(viewModel, inner = inner) }
}
@Composable

View File

@@ -76,34 +76,45 @@ 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InlineCarrousel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingCommunityInPost
import com.vitorpamplona.amethyst.ui.elements.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.elements.DisplayReward
import com.vitorpamplona.amethyst.ui.elements.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.elements.Reward
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.AudioHeader
import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.CreateImageHeader
import com.vitorpamplona.amethyst.ui.note.DisplayEditStatus
import com.vitorpamplona.amethyst.ui.note.DisplayHighlight
import com.vitorpamplona.amethyst.ui.note.DisplayLocation
import com.vitorpamplona.amethyst.ui.note.DisplayOts
import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList
import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet
import com.vitorpamplona.amethyst.ui.note.EditState
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadAndDisplayUser
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
@@ -112,12 +123,17 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent
import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.RenderPoll
import com.vitorpamplona.amethyst.ui.note.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.observeEdits
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -131,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
@@ -144,9 +161,13 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
@@ -154,12 +175,15 @@ import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@Composable
@@ -361,6 +385,8 @@ fun NoteMaster(
.fillMaxWidth()
.padding(top = 10.dp),
) {
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
Row(
modifier =
Modifier
@@ -391,6 +417,12 @@ fun NoteMaster(
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
}
if (editState.value is GenericLoadable.Loaded) {
(editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.let {
DisplayEditStatus(it)
}
}
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colorScheme.placeholderText,
@@ -408,7 +440,7 @@ fun NoteMaster(
tint = MaterialTheme.colorScheme.placeholderText,
)
NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel)
NoteDropDownMenu(baseNote, moreActionsExpanded, editState, accountViewModel, nav)
}
}
@@ -434,6 +466,8 @@ fun NoteMaster(
if (pow > 20) {
DisplayPoW(pow)
}
DisplayOts(note, accountViewModel)
}
}
}
@@ -444,6 +478,8 @@ fun NoteMaster(
BadgeDisplay(baseNote = note)
} else if (noteEvent is LongTextNoteEvent) {
RenderLongFormHeaderForThread(noteEvent)
} else if (noteEvent is WikiNoteEvent) {
RenderWikiHeaderForThread(noteEvent, accountViewModel, nav)
} else if (noteEvent is ClassifiedsEvent) {
RenderClassifiedsReaderForThread(noteEvent, note, accountViewModel, nav)
}
@@ -512,6 +548,12 @@ fun NoteMaster(
accountViewModel,
nav,
)
} else if (noteEvent is FhirResourceEvent) {
RenderFhirResource(baseNote, accountViewModel, nav)
} else if (noteEvent is GitRepositoryEvent) {
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
} else if (noteEvent is GitPatchEvent) {
RenderGitPatchEvent(baseNote, false, true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
@@ -528,6 +570,16 @@ fun NoteMaster(
)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is TextNoteModificationEvent) {
RenderTextModificationEvent(
note = baseNote,
makeItShort = false,
canPreview = true,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else if (noteEvent is PollNoteEvent) {
val canPreview =
note.author == account.userProfile() ||
@@ -553,6 +605,7 @@ fun NoteMaster(
false,
canPreview,
backgroundColor,
editState,
accountViewModel,
nav,
)
@@ -564,10 +617,10 @@ fun NoteMaster(
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = DoubleVertSpacer)
DisplayZapSplits(noteEvent, accountViewModel, nav)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
ReactionsRow(note, true, accountViewModel, nav)
ReactionsRow(note, true, editState, accountViewModel, nav)
Divider(
thickness = DividerThickness,
@@ -797,3 +850,144 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
}
}
}
@Preview
@Composable
private fun RenderWikiHeaderForThreadPreview() {
val event = Event.fromJson("{\"id\":\"277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1\",\"pubkey\":\"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c\",\"created_at\":1708695717,\"kind\":30818,\"tags\":[[\"d\",\"amethyst\"],[\"a\",\"30818:f03e7c5262648e0b7823dfb49f8f17309cfec9cb14711413dcabdf3d7fc6369a:amethyst\",\"wss://relay.nostr.band\",\"fork\"],[\"e\",\"ceabc60c8022c472c727aa25ae7691885964366386ce265c47e5a78be6cb00be\",\"wss://relay.nostr.band\",\"fork\"],[\"title\",\"Amethyst\"],[\"published_at\",\"1708707133\"]],\"content\":\"An Android-only app written in Kotlin with support for over 90 event kinds. \\n\\n![](https://play-lh.googleusercontent.com/lvZlAm9dBrpHeOo7sIPKCsiKOLYLhR2b0FiOT4tyiwWO2dvsR2gDS0xk9tOOr9U-6uM=w240-h480-rw)\\n\",\"sig\":\"6748126a909a20dbdb67947a09d64e41d7140a79335a4ad675c6173d7dd5dbcab9c360dec617bd67bbbc20dfad416b15056eda2e20716cd6c425a84301a125a0\"}") as WikiNoteEvent
val accountViewModel = mockAccountViewModel()
val nav: (String) -> Unit = {}
val editState =
remember {
mutableStateOf(GenericLoadable.Empty<EditState>())
}
runBlocking {
withContext(Dispatchers.IO) {
LocalCache.justConsume(event, null)
}
}
LoadNote(baseNoteHex = "277f982a4cd3f67cc47ad9282176acabee1713848f547d6021e0c155572078e1", accountViewModel = accountViewModel) { baseNote ->
ThemeComparisonColumn(
onDark = {
val bg = MaterialTheme.colorScheme.background
val backgroundColor =
remember {
mutableStateOf(bg)
}
Column {
RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav)
RenderTextEvent(
baseNote!!,
false,
true,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
},
onLight = {
val bg = MaterialTheme.colorScheme.background
val backgroundColor =
remember {
mutableStateOf(bg)
}
Column {
RenderWikiHeaderForThread(noteEvent = event, accountViewModel = accountViewModel, nav)
RenderTextEvent(
baseNote!!,
false,
true,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
},
)
}
}
@Composable
private fun RenderWikiHeaderForThread(
noteEvent: WikiNoteEvent,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val forkedAddress = remember(noteEvent) { noteEvent.forkFromAddress() }
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp)) {
Column {
noteEvent.image()?.let {
AsyncImage(
model = it,
contentDescription =
stringResource(
R.string.preview_card_image_for,
it,
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
)
}
noteEvent.title()?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth(),
)
}
forkedAddress?.let {
LoadAddressableNote(aTag = it, accountViewModel = accountViewModel) { originalVersion ->
if (originalVersion != null) {
ForkInformationRow(originalVersion, Modifier.fillMaxWidth(), accountViewModel, nav)
}
}
}
noteEvent
.summary()
?.ifBlank { null }
?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
modifier = Modifier.fillMaxWidth(),
color = Color.Gray,
)
}
}
}
}
@Composable
fun ForkInformationRow(
originalVersion: Note,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteState by originalVersion.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author ?: return
val route = remember(note) { routeFor(note, accountViewModel.userProfile()) }
if (route != null) {
Row(modifier) {
Text(stringResource(id = R.string.forked_from))
Spacer(modifier = StdHorzSpacer)
LoadAndDisplayUser(author, route, nav)
}
}
}

View File

@@ -39,7 +39,7 @@ fun RefreshingFeedUserFeedView(
nav: (String) -> Unit,
enablePullRefresh: Boolean = true,
) {
RefresheableView(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
RefresheableBox(viewModel, enablePullRefresh) { UserFeedView(viewModel, accountViewModel, nav) }
}
@Composable

View File

@@ -43,7 +43,6 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
@@ -69,7 +68,8 @@ import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.SettingsState
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.Event
@@ -96,6 +96,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Locale
import kotlin.coroutines.resume
@@ -528,7 +529,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
fun broadcast(note: Note) {
account.broadcast(note)
viewModelScope.launch(Dispatchers.IO) { account.broadcast(note) }
}
fun timestamp(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.timestamp(note) }
}
var lastTimeItTriedToUpdateAttestations: Long = 0
fun upgradeAttestations() {
// only tries to upgrade every hour
val now = TimeUtils.now()
if (now - lastTimeItTriedToUpdateAttestations > TimeUtils.ONE_HOUR) {
lastTimeItTriedToUpdateAttestations = now
viewModelScope.launch(Dispatchers.IO) { account.updateAttestations() }
}
}
fun delete(note: Note) {
@@ -647,6 +663,12 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
account.updateShowSensitiveContent(null)
}
fun markDonatedInThisVersion() {
viewModelScope.launch {
account.markDonatedInThisVersion()
}
}
fun defaultZapType(): LnZapEvent.ZapType {
return account.defaultZapType
}
@@ -800,7 +822,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
fun retrieveRelayDocument(
dirtyUrl: String,
onInfo: (RelayInformation) -> Unit,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -846,6 +868,22 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
viewModelScope.launch(Dispatchers.IO) { onResult(checkGetOrCreateNote(key)) }
}
fun checkGetOrCreateNote(
event: Event,
onResult: (Note?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
var note = checkGetOrCreateNote(event.id)
if (note == null) {
LocalCache.verifyAndConsume(event, null)
note = checkGetOrCreateNote(event.id)
}
onResult(note)
}
}
fun getNoteIfExists(hex: HexKey): Note? {
return LocalCache.getNoteIfExists(hex)
}
@@ -876,11 +914,33 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
return LocalCache.addressables[key]
}
fun findStatusesForUser(
suspend fun findStatusesForUser(
myUser: User,
onResult: (ImmutableList<AddressableNote>) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) { onResult(LocalCache.findStatusesForUser(myUser)) }
withContext(Dispatchers.IO) {
onResult(LocalCache.findStatusesForUser(myUser))
}
}
suspend fun findOtsEventsForNote(
note: Note,
onResult: (Long?) -> Unit,
) {
withContext(Dispatchers.IO) {
onResult(LocalCache.findEarliestOtsForNote(note))
}
}
fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note)
suspend fun findModificationEventsForNote(
note: Note,
onResult: (List<Note>) -> Unit,
) {
withContext(Dispatchers.IO) {
onResult(LocalCache.findLatestModificationForNote(note))
}
}
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
@@ -937,7 +997,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
fun returnNIP19References(
content: String,
tags: ImmutableListOfLists<String>?,
onNewReferences: (List<Nip19.Return>) -> Unit,
onNewReferences: (List<Nip19Bech32.Entity>) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
onNewReferences(MarkdownParser().returnNIP19References(content, tags))
@@ -954,17 +1014,37 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
}
fun parseNIP19(
fun loadNEmbedIfNeeded(nembed: Event) {
val baseNote = LocalCache.getNoteIfExists(nembed.id)
if (baseNote?.event == null) {
LocalCache.verifyAndConsume(nembed, null)
}
}
suspend fun parseNIP19(
str: String,
onNote: (LoadedBechLink) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
Nip19.uriToRoute(str)?.let {
withContext(Dispatchers.IO) {
Nip19Bech32.uriToRoute(str)?.let {
var returningNote: Note? = null
if (
it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS
) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> returningNote = note }
when (val parsed = it.entity) {
is Nip19Bech32.NSec -> {}
is Nip19Bech32.NPub -> {}
is Nip19Bech32.NProfile -> {}
is Nip19Bech32.Note -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
is Nip19Bech32.NEvent -> LocalCache.checkGetOrCreateNote(parsed.hex)?.let { note -> returningNote = note }
is Nip19Bech32.NEmbed -> {
loadNEmbedIfNeeded(parsed.event)
LocalCache.checkGetOrCreateNote(parsed.event.id)?.let { note ->
returningNote = note
}
}
is Nip19Bech32.NRelay -> {}
is Nip19Bech32.NAddress -> LocalCache.checkGetOrCreateNote(parsed.atag)?.let { note -> returningNote = note }
else -> {}
}
onNote(LoadedBechLink(returningNote, it))
@@ -1094,6 +1174,7 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
"Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}",
)
invalidateInsertData(newNotes)
upgradeAttestations()
}
}
}
@@ -1264,7 +1345,7 @@ class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
}
}
@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
@Immutable data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19Bech32.ParseReturn)
public fun <T, K> allOrNothingSigningOperations(
remainingTos: List<T>,

View File

@@ -101,6 +101,7 @@ import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.MediaUrlVideo
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
@@ -120,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
@@ -681,7 +681,7 @@ fun ShowVideoStreaming(
) {
val zoomableUrlVideo =
remember(it) {
ZoomableUrlVideo(
MediaUrlVideo(
url = url,
description = title,
artworkUri = artworkUri,
@@ -710,8 +710,8 @@ fun ShortChannelHeader(
nav: (String) -> Unit,
showFlag: Boolean,
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState.value?.channel } ?: return
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val automaticallyShowProfilePicture =
remember {
@@ -782,8 +782,8 @@ fun LongChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val channelState = baseChannel.live.observeAsState()
val channel = remember(channelState) { channelState.value?.channel } ?: return
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
Row(
lineModifier,
@@ -865,7 +865,7 @@ fun LongChannelHeader(
)
Spacer(DoubleHorzSpacer)
NormalTimeAgo(note, remember { Modifier.weight(1f) })
MoreOptionsButton(note, accountViewModel)
MoreOptionsButton(note, null, accountViewModel, nav)
}
}
}

View File

@@ -45,7 +45,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -106,7 +105,6 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
@@ -115,6 +113,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
@@ -661,11 +660,7 @@ private fun EditRoomSubjectButton(
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { wantsToPost = true },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
contentPadding = ZeroPadding,
) {
Icon(
tint = Color.White,

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