Compare commits

...

286 Commits

Author SHA1 Message Date
Vitor Pamplona
d73dec8f0b v0.74.5 2023-08-21 18:31:00 -04:00
Vitor Pamplona
c6124376df Merge pull request #552 from greenart7c3/fix-crash
fix crash when getting firebase token without play services
2023-08-21 18:30:02 -04:00
Vitor Pamplona
87c19f4232 BugFix for ClassCastException when the event changes 2023-08-21 18:21:28 -04:00
greenart7c3
a1f116909f fix crash when not using play services 2023-08-21 19:20:14 -03:00
Vitor Pamplona
ca9951f9b8 Fixes the loading of GiftWraps as intents in the app. 2023-08-21 18:19:10 -04:00
Vitor Pamplona
2bedd79e6f Fixing search for root to go backwards in the etag-stack. 2023-08-21 18:17:21 -04:00
Vitor Pamplona
a9af2d8982 v0.74.4 2023-08-21 17:30:37 -04:00
Vitor Pamplona
655a563f61 Moves community description and rules to a single Translateable text 2023-08-21 16:09:22 -04:00
Vitor Pamplona
9777c709a1 - Fixes some of the TopBar padding issues.
- Adds more info tap on single-user chat rooms
- Moves creation date to a new line.
2023-08-21 15:42:45 -04:00
Vitor Pamplona
251f4854fc Copies to Clipboard the GiftWrap ID and not the inner message ID 2023-08-21 14:23:36 -04:00
Vitor Pamplona
43c32f0b0e Moves last scope to a class-bound variable. 2023-08-21 13:51:03 -04:00
Vitor Pamplona
ec3b07147c Avoids creating new corotine scopes 2023-08-21 13:34:18 -04:00
Vitor Pamplona
0e6a2c339e Fixes the update of the feed after adding an image on stories. 2023-08-20 21:24:57 -04:00
Vitor Pamplona
844f7cd478 Reports on the number of observers to the cache. 2023-08-20 21:24:23 -04:00
Vitor Pamplona
a2edc9f3b2 Fixes the divider presence in Live Activities and Notifications 2023-08-20 17:31:36 -04:00
Vitor Pamplona
35d09aee77 - Filter follows in the list of participants
- Uses KIND3 follows for the notification dot in the bottom nav bar
2023-08-20 17:08:24 -04:00
Vitor Pamplona
a79d1d5bd6 Fixes issue with unfollowing hashtag written in a different case 2023-08-20 16:46:44 -04:00
Vitor Pamplona
ba9315fcd1 Moves coroutines of BundledUpdate and BundledInsert to a managed model. 2023-08-20 16:37:49 -04:00
Vitor Pamplona
f5c71b4f90 Broadcasts a note when a person replies to it. 2023-08-20 16:35:34 -04:00
Vitor Pamplona
5a40b39952 v0.74.3 2023-08-20 11:36:00 -04:00
Vitor Pamplona
2ac3b42d4d Correctly gets the Global Relays being used. 2023-08-20 11:33:56 -04:00
Vitor Pamplona
7cfad373f2 Adds Copy URL and Copy Note ID popups for images and videos 2023-08-20 11:18:31 -04:00
Vitor Pamplona
5d061a79e2 Explanation on why the Global and All Follows code have spaces 2023-08-20 10:29:35 -04:00
Vitor Pamplona
eb59a9d505 Keeps track of a message's GiftWrap host in order to broadcast later. 2023-08-20 10:24:11 -04:00
Vitor Pamplona
784f867a8e Updating media controls to the next version 2023-08-20 01:16:08 -04:00
Vitor Pamplona
954a9d66fb Fixes the crash when opening a malformed thread with a wrong root marker 2023-08-20 01:15:46 -04:00
Vitor Pamplona
dfdced3e8c Includes the zaps from the user's own key in the Summary Chart 2023-08-19 14:57:24 -04:00
Vitor Pamplona
0af141c5b7 Fixes generateForegroundServiceDidNotStartInTimeException on Samsung phones 2023-08-19 14:44:39 -04:00
Vitor Pamplona
70c4c69bbf v0.74.2 2023-08-19 12:40:26 -04:00
Vitor Pamplona
5597189ea5 Allowing null values as a result of the mapper 2023-08-19 12:38:44 -04:00
Vitor Pamplona
8884adcefb Testing new build to find bug on Account loading: 0.74.1 2023-08-19 11:58:08 -04:00
Vitor Pamplona
92be03ca51 v0.74.0 2023-08-19 11:16:13 -04:00
Vitor Pamplona
b4f9103b62 refacctoring 2023-08-19 11:14:10 -04:00
Vitor Pamplona
63f3a6cdf9 Implements contextual rounded corners for images and videos 2023-08-19 11:08:55 -04:00
Vitor Pamplona
d8ce3b9d37 Migrates to prefixes of 8 because more relays seem to work with this 2023-08-18 23:43:16 -04:00
Vitor Pamplona
2ac5742b0e Adds a waveform to audio files 2023-08-18 23:42:24 -04:00
Vitor Pamplona
58dd69091e Adds support for Stemstr 2023-08-18 20:36:32 -04:00
Vitor Pamplona
7a93f9956f Updating SDK to the latest 2023-08-18 19:32:30 -04:00
Vitor Pamplona
8dbded0185 Single method to test performance of receiving a GiftWrap. 2023-08-18 11:52:54 -04:00
Vitor Pamplona
41b93e3860 Caches shared key for performance. 2023-08-18 10:55:28 -04:00
Vitor Pamplona
2f741779fa Improves BechUtils performance. 2023-08-18 10:55:06 -04:00
Vitor Pamplona
918273d825 showing debug when pausing the app. 2023-08-17 17:08:02 -04:00
Vitor Pamplona
eef534617c Fixing ThreadAssembler with two roots 2023-08-17 15:15:13 -04:00
Vitor Pamplona
4e89b1494d Removes experimental time from ThreadAssembler 2023-08-17 10:23:18 -04:00
Vitor Pamplona
be1ae35a60 Fixing Proguard from latest refactoring 2023-08-17 10:23:00 -04:00
Vitor Pamplona
47395465bb Automatically activates NIP24 chats when users have received NIP24 messages from the counterparty 2023-08-16 21:16:32 -04:00
Vitor Pamplona
c49f89f711 Fixes the color of the chart for changes in the Apps theme in settings 2023-08-16 21:03:50 -04:00
Vitor Pamplona
6721c83266 Back button was too bright. 2023-08-16 18:28:08 -04:00
Vitor Pamplona
ce27be2053 Fixes room names when clients send the same user twice in the ptags. 2023-08-16 18:10:56 -04:00
Vitor Pamplona
fee2a0e4f8 Fixes an incorrect import. 2023-08-16 18:05:10 -04:00
Vitor Pamplona
52fcfebe38 Fixes references in F-droid 2023-08-16 18:03:56 -04:00
Vitor Pamplona
0e1a856555 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-16 17:58:40 -04:00
Vitor Pamplona
255b48d126 - Creates a Quartz Module for Nostr Events
- Creates a Benchmark Module to test Performance
- Migrates from GSon to Jackson for Performance Gains, adapts all serializers accordingly
- Recreates Hex encoding/decoding classes for Performance.
- Migrates NIP24 to the new ByteArray Concat encoding.
- Removes support for Lenient choices in the. events.
- Reorganizes Nostr Events dependencies.
- Refactors TLV's and NIP-19 dependencies.
- Adds a Large DB for signature checks.
2023-08-16 17:58:25 -04:00
Vitor Pamplona
99adbf6691 Merge pull request #549 from ShinoharaTa/fix_broken_url
fix url regex pattern.
2023-08-16 10:39:32 -04:00
Vitor Pamplona
a96760d1a4 Merge pull request #551 from davotoula/update_translations
Added new translations for cs/de/se
2023-08-16 09:47:25 -04:00
David Kaspar
dd9e51e31c Added new translations for cs/de/se 2023-08-16 13:57:56 +01:00
tshinohara
a5b607b30b fix url regax pattern. 2023-08-16 02:48:50 +09:00
Vitor Pamplona
f7e9898637 Testing image uploads with Account info 2023-08-15 11:57:28 -04:00
Vitor Pamplona
1ad1d233cd Refactoring TLV's code 2023-08-15 11:13:01 -04:00
Vitor Pamplona
52687a48df Fixes image uploading tests without account. 2023-08-15 11:10:17 -04:00
Vitor Pamplona
dca4f827fe Moves navigation's top bar to use the ArrowBack 2023-08-14 23:07:08 -04:00
Vitor Pamplona
2726011126 Trim display names when possible 2023-08-14 23:06:33 -04:00
Vitor Pamplona
77bd646b1d Don't display username if display name is available. 2023-08-14 23:06:18 -04:00
Vitor Pamplona
d664d9ff19 Moves Relay List to a surface 2023-08-14 23:05:50 -04:00
Vitor Pamplona
5d24950d7f Starts NIP-65 implementation 2023-08-14 23:05:16 -04:00
Vitor Pamplona
3a8a50006d Improvements to the Settings Interface 2023-08-14 21:25:12 -04:00
Vitor Pamplona
decd6ef627 BugFix for HighlightEvent.create event. 2023-08-14 16:38:54 -04:00
Vitor Pamplona
6ab061a5a2 Avoids displaying a NIP-94 event without a url tag 2023-08-14 16:33:47 -04:00
Vitor Pamplona
d934e28b3a Updates some dependencies while we wait for Android 34's Gradle plugin to be released. 2023-08-14 16:10:43 -04:00
Vitor Pamplona
57588fd6ac no need for these idea files in GitHub 2023-08-14 16:05:01 -04:00
Vitor Pamplona
921d52cb8c Moderated communities was renamed. 2023-08-14 15:12:18 -04:00
Vitor Pamplona
4a2d673cce Updates Readme for the new Private DMs 2023-08-14 15:02:57 -04:00
Vitor Pamplona
c7a3a74d5a v0.73.3 2023-08-13 17:52:13 -04:00
Vitor Pamplona
eed2f6abaf Unused null section 2023-08-13 17:51:55 -04:00
Vitor Pamplona
80f0b14c02 BugFix for blank screen when pressing the message button without any history of chats 2023-08-13 17:38:44 -04:00
Vitor Pamplona
b12b81282c BugFix for the route in the DM Notification 2023-08-13 17:24:30 -04:00
Vitor Pamplona
07d661ea00 Only updates the Bookmarks and Follows flag when the sensitive content property of the account changes. 2023-08-12 21:40:09 -04:00
Vitor Pamplona
a15263eeb9 Moves zaps from Compose scope to ViewModel Scope 2023-08-12 21:38:14 -04:00
Vitor Pamplona
6f19bccc54 Observes the user's follow list and not the local account state to update TagLists on the screen 2023-08-12 21:37:31 -04:00
Vitor Pamplona
547d567431 Avoids account state redirections 2023-08-12 21:36:31 -04:00
Vitor Pamplona
ece4e9074a Updates the isHidden function to the same version the NoteCompose uses. 2023-08-12 21:36:13 -04:00
Vitor Pamplona
0737c6ddc3 Avoids a redirection of account State 2023-08-12 21:35:50 -04:00
Vitor Pamplona
2aa9458aa1 Moves Show And Hide user functions from ComposeScoped to ViewModel Scoped 2023-08-12 21:35:34 -04:00
Vitor Pamplona
cb3b898e29 Moves the creation of the TopBar livedata for lists into the AccountViewModel 2023-08-12 21:33:29 -04:00
Vitor Pamplona
ceac61eb19 Avoiding live data updates 2023-08-12 21:32:15 -04:00
Vitor Pamplona
bc9f5e5328 Moves HTTP regex compilation to outside the inner method 2023-08-12 21:31:49 -04:00
Vitor Pamplona
a293800897 Moves Regex compilation to outside the state class. 2023-08-12 21:31:10 -04:00
Vitor Pamplona
3be34d0d40 Speeding up regex 2023-08-12 18:15:45 -04:00
Vitor Pamplona
eec87f017a v0.73.2 2023-08-12 15:18:26 -04:00
Vitor Pamplona
799037502f Correctly displaying/hiding pictures in Chat Compose 2023-08-12 15:17:05 -04:00
Vitor Pamplona
f2b727c587 Fixes the PushNotification of GiftWrapped Messages 2023-08-12 15:00:30 -04:00
Vitor Pamplona
b255d1827e Forces the creation of Notification Channels to see if they show up on Samsung phones. 2023-08-12 14:59:53 -04:00
Vitor Pamplona
d9e01da7e2 Removes unnecessary code 2023-08-12 14:59:14 -04:00
Vitor Pamplona
99a98439cd Uses the right account to decrypt GiftWraps 2023-08-12 14:56:29 -04:00
Vitor Pamplona
724e7e2378 Merge pull request #547 from KotlinGeekDev/fix-landscape-issue
Fix menu drawer in landscape mode issue
2023-08-12 14:15:43 -04:00
Vitor Pamplona
0619c9ffac Displays GiftWraps as Messages in the Notification Screen 2023-08-12 14:12:46 -04:00
Vitor Pamplona
cb7b51e8d1 BugFix for marking chat as read 2023-08-12 13:50:40 -04:00
Vitor Pamplona
c8172265dd v0.73.1 2023-08-12 11:20:13 -04:00
Vitor Pamplona
cce9d424bc Fixes green color for incognito mode 2023-08-12 11:20:01 -04:00
Vitor Pamplona
45227b75dc Fixed ProGuard for libsodium 2023-08-12 11:15:20 -04:00
Vitor Pamplona
6eaba7956c Sets releases as pre-release by default. 2023-08-12 10:31:08 -04:00
Vitor Pamplona
cc4b94f738 v0.73.0 2023-08-12 10:10:09 -04:00
Vitor Pamplona
423628a104 Adding ways to update the subject 2023-08-12 10:04:15 -04:00
Vitor Pamplona
d262b48f31 BugFix: Chat List updating immediately when new messages arrive. 2023-08-11 20:04:37 -04:00
Vitor Pamplona
f97a0468cc BugFix for when the private room is with yourself. 2023-08-11 19:47:17 -04:00
Vitor Pamplona
b4d87686ac Adds an incognito badge in every msg 2023-08-11 19:46:59 -04:00
Vitor Pamplona
8cbf07c917 Moves GiftCard filter to Account filters and not Chats 2023-08-11 19:46:26 -04:00
Vitor Pamplona
30fc4c35f4 Only requests notification once. 2023-08-11 19:44:34 -04:00
Vitor Pamplona
7912d492e5 Makes sure replies of NIP24 are also NIP24 2023-08-11 19:43:36 -04:00
KotlinGeekDev
d921eb6138 Preserve behaviour pre-fix(i.e if a user opened the drawer and landscaped by choice). 2023-08-11 23:32:27 +01:00
KotlinGeekDev
6875fdc4d0 Watch orientation change, and close drawer in landscape mode. 2023-08-11 23:27:04 +01:00
Vitor Pamplona
0bc701f5c9 Adding a way to visualize all members of a group 2023-08-10 21:27:30 -04:00
Vitor Pamplona
5de0808b1e Merge pull request #545 from jeremyd/fix_global_relay_selection
fixing global relays selection
2023-08-10 20:06:59 -04:00
Vitor Pamplona
9d680c9f82 Merge pull request #546 from Bardesss/main-1
Update strings.xml (NL)
2023-08-10 20:06:06 -04:00
Vitor Pamplona
63fc4c570d Fixed Alby NWC link 2023-08-10 18:04:38 -04:00
Vitor Pamplona
ab2fff0194 Support for NIP24- Trustless GiftWrapped Sealed Private Direct Messages and Small Private Groups 2023-08-10 18:04:23 -04:00
Bardesss
c644ba9a3e Update strings.xml (NL)
Added missing strings.
2023-08-10 09:17:32 +00:00
jeremyd
2791873048 fixing global relays selection 2023-08-08 19:42:19 -07:00
Vitor Pamplona
89266bc76f Merge pull request #542 from frnandu/main
fix signed byte used for array slice with + added test
2023-08-07 09:14:27 -04:00
Fernando Martins
9573b4abec fix signed byte used for array slice with + added test 2023-08-07 11:56:11 +02:00
Vitor Pamplona
ddcff8fa15 don't return reposts and reactions in Search 2023-08-05 10:44:20 -04:00
Vitor Pamplona
d36f6f4a7e v0.72.2 2023-08-04 21:06:14 -04:00
Vitor Pamplona
c2b72f1e45 TimeAgo refactoring 2023-08-04 21:04:13 -04:00
Vitor Pamplona
28e6471ade Puts marked as read on chat to an IO thread 2023-08-04 21:03:19 -04:00
Vitor Pamplona
c3b8eb5087 Puts an invalidate data on an IO thread 2023-08-04 21:02:45 -04:00
Vitor Pamplona
75779e7ddc simple refactoring 2023-08-04 21:00:07 -04:00
Vitor Pamplona
118bc7b73c Don't invalidate data on channels if it is already invalidating 2023-08-04 20:58:10 -04:00
Vitor Pamplona
e6a0fdedd9 Removes unnecessary signing step 2023-08-04 20:57:21 -04:00
Vitor Pamplona
28098fafd7 Don't push to invalidate unless there is an observer. 2023-08-04 20:56:22 -04:00
Vitor Pamplona
0c0e87af21 Moves headers to the top nav bar 2023-08-04 16:50:23 -04:00
Vitor Pamplona
8abfd7149b Removes the need for .0 when on base sats. 2023-08-04 15:24:41 -04:00
Vitor Pamplona
674896cea4 Moves coroutine creation to the viewModel 2023-08-04 15:20:31 -04:00
Vitor Pamplona
ec514651fc Makes the New Channel creation scrollable. 2023-08-04 15:18:30 -04:00
Vitor Pamplona
8071d48911 updates github action to support minified versions of the app 2023-08-04 14:24:11 -04:00
Vitor Pamplona
69daa3835b v0.72.1 2023-08-04 13:18:15 -04:00
Vitor Pamplona
57dac276b2 Simple refactoring of the zap author and comment calculator function 2023-08-04 13:11:35 -04:00
Vitor Pamplona
3a84595aac Expanded DiskCache for images 2023-08-04 12:35:42 -04:00
Vitor Pamplona
7b591c84f8 Moves NIP95 cache to internal/secure folders 2023-08-04 12:32:36 -04:00
Vitor Pamplona
e8c8167451 Fixes the interference between simplecache and image chaces. 2023-08-04 12:25:12 -04:00
Vitor Pamplona
ec93fc53b2 Adds a cache forever tag to robohashes 2023-08-04 12:17:19 -04:00
Vitor Pamplona
ba2902b725 Moves URL Previews into suspend functions 2023-08-04 10:47:01 -04:00
Vitor Pamplona
e7defa14b9 Adds a little border for Long Form Content 2023-08-04 10:38:10 -04:00
Vitor Pamplona
4fbc3f25b3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-04 10:25:42 -04:00
Vitor Pamplona
4ae88d5cc8 Fixes an invalid string in values-fr 2023-08-04 10:25:34 -04:00
Vitor Pamplona
1aea0720df Merge pull request #540 from believethehype/nostrbuildv2
Nostr.build API v2, NIP98 support
2023-08-04 10:21:14 -04:00
believethehype
846f340f84 Merge branch 'vitorpamplona:main' into nostrbuildv2 2023-08-04 11:56:05 +02:00
Vitor Pamplona
cb137205c0 Fixes french translations 2023-08-03 17:16:55 -04:00
Vitor Pamplona
f910709e1b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-03 17:05:26 -04:00
Vitor Pamplona
1d78dd6b19 Merge pull request #537 from anthony-robin/update-french-translations
[Translations] Update french texts
2023-08-03 17:05:19 -04:00
Anthony
3197be8a75 Remove translatable="false" keys 2023-08-03 22:00:51 +02:00
Anthony
91ed4128fe More french translations tweaks 2023-08-03 21:59:39 +02:00
Anthony
f415490fcd Update more french translations 2023-08-03 21:41:58 +02:00
Vitor Pamplona
4bdb5a28d0 - Fixes new lines after images
- Fixes urls with - https://github.com/vitorpamplona/amethyst/issues/536
2023-08-02 18:56:01 -04:00
Anthony
58efe0b3c0 Update french translations 2023-08-02 23:47:28 +02:00
Vitor Pamplona
da3cecd324 Displaying a bytecounter for events in memory when the app pauses. 2023-08-02 16:34:18 -04:00
Vitor Pamplona
09b1451dc1 Sets and Maps are 5x more memory intensive than Lists. This should help with memory management. 2023-08-02 16:33:53 -04:00
Vitor Pamplona
9ae31a3429 Reverting the commented galleries 2023-08-02 16:28:17 -04:00
Vitor Pamplona
b5258f89c1 Correcting the acceptable filter 2023-08-02 16:27:41 -04:00
Vitor Pamplona
fcc67fc4f2 Faster Galleries 2023-08-02 14:32:35 -04:00
Vitor Pamplona
579465ac6c Avoids null model errors that might be delaying some of the rendering. 2023-08-02 13:29:44 -04:00
Vitor Pamplona
6cbee81e72 Moves onCreate intent evaluation to the App Navigation 2023-08-02 13:29:04 -04:00
Believethehype
ad683ed444 added nostr.build api v2 / nip98
replaced nostr.build api with v2

Added NIP98 to nostr.build so images get added to premium account galleries, if available.
2023-08-02 04:14:35 +02:00
Vitor Pamplona
c53b1571a3 Additional classes to be serialized 2023-08-01 21:56:30 -04:00
Vitor Pamplona
2c19b2ffef Moves Geolocation search to the IO thread 2023-08-01 21:52:18 -04:00
Vitor Pamplona
3694d1b0f5 Avoids using String format. 2023-08-01 21:51:47 -04:00
Vitor Pamplona
e7a4746d22 Faster method to generate the hex of a url 2023-08-01 21:51:15 -04:00
Vitor Pamplona
7961f6f065 Removes unnecessary onIntent override 2023-08-01 21:50:49 -04:00
Vitor Pamplona
7c1c500990 Prepares for a minified release. 2023-08-01 21:49:30 -04:00
Vitor Pamplona
5f85d8a97b Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-08-01 11:10:10 -04:00
Vitor Pamplona
eb77db0469 v0.72.0 2023-08-01 11:10:05 -04:00
Vitor Pamplona
a62445a52f Merge pull request #535 from velram/main
Translations - Tamil tech terms updated
2023-08-01 11:01:38 -04:00
Vitor Pamplona
553a1fe673 Avoids showing community definition types in the community feed. 2023-08-01 10:39:31 -04:00
Vitor Pamplona
a50b0b7902 Moves navController back into Compose
Adds a listener for newIntents
Fixes the App losing the back stack of screens
2023-08-01 10:39:10 -04:00
Vel
23029a2022 Merge branch 'vitorpamplona:main' into main 2023-08-01 05:20:47 +05:30
Vitor Pamplona
bd7156082b Fixing the correct EOSE cleanup 2023-07-31 18:06:16 -04:00
Vitor Pamplona
bdd2a97ee3 formatting 2023-07-31 17:56:14 -04:00
Vitor Pamplona
a2acec7900 Testing more aggressive memory management 2023-07-31 17:55:51 -04:00
Vitor Pamplona
c4a9a2d557 Fixes the createAt issue on NIP24b 2023-07-31 16:46:58 -04:00
Vitor Pamplona
24481459a7 improves dump when pausing. 2023-07-31 14:19:40 -04:00
Vitor Pamplona
14d2d7439e Ignoring past version of addressable content in feeds 2023-07-31 11:14:15 -04:00
Vitor Pamplona
dae8877983 Updates JNA dependency 2023-07-31 10:29:41 -04:00
Vitor Pamplona
81f2e72f0d Allows notification grouping by app 2023-07-31 10:29:22 -04:00
Vel
a993e4b378 Merge pull request #1 from velram/tamil_translations
Tamil translations updated with Tamil tech terms
2023-07-31 09:08:45 +05:30
Coder
de14d4e0f1 Tamil translations updated for various technical words 2023-07-30 22:40:18 -04:00
Vitor Pamplona
fd58f063ac Removes unused values in strings 2023-07-30 18:15:26 -04:00
Vitor Pamplona
4593ce582d Removes leakcanary due to irrelevance 2023-07-30 18:15:08 -04:00
Vitor Pamplona
8d8fb8f5dc Updates to the latest libraries 2023-07-30 18:10:23 -04:00
Vitor Pamplona
3e4a4f7442 Testing if interning pubkeys and ids help save some memory. 2023-07-30 17:48:35 -04:00
Vitor Pamplona
d5ad6b983a Reviews event-based filters. 2023-07-30 16:55:09 -04:00
Vitor Pamplona
30d31b35ab Filters the search to avoid getting 1000s of Wallet Connect and Nostr Connect messages a second. 2023-07-30 16:40:17 -04:00
Vitor Pamplona
18839b8853 Adds new kinds to the hashtag and geotag search 2023-07-30 16:39:12 -04:00
Vitor Pamplona
e46a77f5ef Reverts to use the messageTitle as Group 2023-07-30 16:30:58 -04:00
Vitor Pamplona
2c8c0a8073 Improves search by looking into all downloaded events. 2023-07-30 16:28:30 -04:00
Coder
570bf1c2e7 Tamil translations based on references 2023-07-30 15:00:40 -04:00
Coder
1d1dc757ee Revert "Tamil translations updated - tech terms updated based on lexicon and refs from Tamil profs"
This reverts commit 50f0085118.
2023-07-30 14:34:31 -04:00
Coder
50f0085118 Tamil translations updated - tech terms updated based on lexicon and refs from Tamil profs 2023-07-30 13:55:58 -04:00
Vitor Pamplona
1edfc6bfbd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-07-30 13:06:58 -04:00
Vitor Pamplona
5596c54107 Requesting GiftWrapped messages in the DM filter 2023-07-30 13:06:53 -04:00
Vitor Pamplona
ffe1d737fb Preparing to keep ChatMessages in the LocalCache. 2023-07-30 13:06:33 -04:00
Vitor Pamplona
9b36b81721 Merge pull request #531 from KotlinGeekDev/grouped-notifications
Grouped notifications.
2023-07-30 13:03:50 -04:00
Vitor Pamplona
800c1e1a44 Merge pull request #533 from davotoula/update_translations_cs_sv_de
Replace hard coded text with resource text and update translations
2023-07-30 13:01:41 -04:00
David Kaspar
a011ef128a Replaced hard coded text with resource strings
Added new translations for cs/de/se
Corrected "occurred"
2023-07-30 17:46:46 +01:00
David Kaspar
2e8760abe5 Replaced hard coded text with resource strings
Added new translations for cs/de/se
Corrected "occurred"
2023-07-30 17:44:18 +01:00
David Kaspar
6acfaa97f3 Added missing translations cs/de/se 2023-07-30 16:55:59 +01:00
Vitor Pamplona
4426ccada7 Improving test 2023-07-29 18:35:52 -04:00
Vitor Pamplona
da02f36663 Refactoring to clarify the use of crypto functions 2023-07-29 16:38:55 -04:00
Vitor Pamplona
e240cd04d4 Final adjustments on the use of XChaCha 2023-07-29 16:00:13 -04:00
Vitor Pamplona
16a7fdffcf Correctly managing Shared Secrets 2023-07-29 15:29:51 -04:00
Vitor Pamplona
2fdb4e47b0 Cryptographic support for NIP24 2023-07-29 12:39:25 -04:00
Vitor Pamplona
a316351ba7 Increases confidence of the Translator 2023-07-29 12:39:02 -04:00
KotlinGeekDev
492bc76957 Use different group keys for DMs and Zaps. 2023-07-29 17:04:55 +01:00
KotlinGeekDev
e8170398e3 Use the same group key, so the system groups automatically. 2023-07-29 15:46:13 +01:00
Vitor Pamplona
ff233c699b Separating the event creation into a Factory 2023-07-26 20:41:46 -04:00
Vitor Pamplona
0d1cbce5de Fixing the explainer text of the geohash addon 2023-07-25 21:49:55 -04:00
Vitor Pamplona
3e7bbc03c1 v0.71.0 2023-07-25 18:54:15 -04:00
Vitor Pamplona
5ba091de0d Allows following of Geohashes 2023-07-25 18:45:28 -04:00
Vitor Pamplona
1098c31787 - Adds support for GeoHash
- Refactors New Post Buttons to make them more similar to one another.
2023-07-25 16:52:32 -04:00
Vitor Pamplona
c20277a754 Improvements on Translation Indexes 2023-07-25 12:06:17 -04:00
Vitor Pamplona
a5e40d5ac9 Reviews closing of response.body calls 2023-07-25 11:20:27 -04:00
Vitor Pamplona
aab42bb341 Refactoring of unused elements in VideoView 2023-07-25 11:13:17 -04:00
Vitor Pamplona
748349244e Fixes memory not closing the response body 2023-07-25 11:13:05 -04:00
Vitor Pamplona
9e9c8befe0 Fixes memory leak when services are destroyed 2023-07-25 11:12:43 -04:00
Vitor Pamplona
b9fb11bdf3 Initializes the VideoCache when creating the app so that the PlaybackService doesn't suffer with delays in accessing the disk to create the cache. 2023-07-25 11:12:25 -04:00
Vitor Pamplona
cd6f9576cc Refactors Relay class to remove the Synchronized block 2023-07-25 10:23:20 -04:00
Vitor Pamplona
b919b165d6 Improves reachability of the relay screen when keyboard is visible 2023-07-25 10:22:52 -04:00
Vitor Pamplona
a2d9a3f770 Runs the DisposableEffect before moving to load the video. 2023-07-25 09:03:56 -04:00
Vitor Pamplona
39fccd5442 Only asks for notification permissions on Tiramisu 2023-07-25 08:46:25 -04:00
Vitor Pamplona
cfeaccb26e Reverts the startup redirection because navController might not be ready in the onCreate 2023-07-25 08:31:50 -04:00
Vitor Pamplona
08a2a2b428 No need for the secp256k1 instance anymore 2023-07-25 08:14:32 -04:00
Vitor Pamplona
297bcf2762 v0.70.8 2023-07-24 18:29:49 -04:00
Vitor Pamplona
092d333378 Fixes Notification Request for Samsung phones 2023-07-24 18:19:08 -04:00
Vitor Pamplona
eb58e9ea39 Fixes a crash when video playback service isn't ready 2023-07-24 18:18:36 -04:00
Vitor Pamplona
63c498da87 v0.70.7 2023-07-24 16:31:33 -04:00
Vitor Pamplona
b40dcb4bd8 Maybe this solves the back button issue in some phones 2023-07-24 16:30:56 -04:00
Vitor Pamplona
49fe1d0743 Fixes bug with Base64 encoding 2023-07-24 16:28:41 -04:00
Vitor Pamplona
25c40b89d7 v0.70.6 2023-07-24 15:39:52 -04:00
Vitor Pamplona
bfe1e78e57 Moves the starting navigation to outside the compose element. 2023-07-24 15:36:40 -04:00
Vitor Pamplona
fa79ddc573 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-07-24 15:22:13 -04:00
Vitor Pamplona
653e8200ca Fixes a crash when onNewIntent is called before onCreate 2023-07-24 15:10:33 -04:00
Vitor Pamplona
8806ca6534 Merge pull request #522 from greenart7c3/main
add a button to return to default relay list
2023-07-24 13:21:11 -04:00
greenart7c3
897306e16e show paid relays after resetting relay list to default 2023-07-24 14:03:42 -03:00
greenart7c3
1e25688f9d add a button to return to default relay list 2023-07-24 13:55:55 -03:00
Vitor Pamplona
2b6abcbfb9 Merge pull request #521 from greenart7c3/main
add missing OptIn when using GlobalScope
2023-07-24 12:19:44 -04:00
greenart7c3
ce33190177 add missing OptIn when using GlobalScope 2023-07-24 13:13:55 -03:00
Vitor Pamplona
d68b14532d Fixing Test cases 2023-07-24 11:54:09 -04:00
Vitor Pamplona
e83cfe4f07 Removes dirty word parser from HexUtils and puts into the New Message Processor. 2023-07-24 11:47:25 -04:00
Vitor Pamplona
d3e9cc0788 Moves Bech32 encoders to Bech32 utils 2023-07-24 11:46:56 -04:00
Vitor Pamplona
87990026ea Refactoring dependency path to center in CryptoUtils 2023-07-24 11:34:16 -04:00
Vitor Pamplona
7923f5d854 Moves Bech Utility methods from CryptoUtils to BechUtils 2023-07-24 11:18:57 -04:00
Vitor Pamplona
69eea8824a Removes the reuse of the SHA256 digest due to buffer sharing issues in a multithreaded environment. 2023-07-24 11:17:01 -04:00
Vitor Pamplona
a1517a67f5 Renames Persona to KeyPair and Utils to CryptoUtils 2023-07-24 11:13:33 -04:00
Vitor Pamplona
480ae479af Imports NostrPostr utility classes directly via source to avoid dependency issues on unnecessary libraries. 2023-07-24 11:09:43 -04:00
Vitor Pamplona
94213ebf83 Quick test for LnInvoice calculation amounts 2023-07-24 10:19:13 -04:00
Vitor Pamplona
eedce96453 Merge pull request #520 from greenart7c3/select_all
Fixes for relay selection dialog
2023-07-24 08:20:19 -04:00
greenart7c3
d6886f51f7 removed unused annotation 2023-07-24 07:56:21 -03:00
greenart7c3
0497f1e625 add select all switch, fix switch not clickable when relay name is too long 2023-07-24 07:46:32 -03:00
Vitor Pamplona
95ac046a09 Simplifying relay connection status 2023-07-23 18:37:37 -04:00
Vitor Pamplona
3e6938bf51 Fixes the use of OkHttpClient 2023-07-23 18:37:05 -04:00
Vitor Pamplona
786b8436f8 Refactoring of the Connectivity Settings 2023-07-23 16:33:18 -04:00
Vitor Pamplona
b055e520c2 Avoids the animations when loading NIP94 and NIP95 content 2023-07-23 16:32:36 -04:00
Vitor Pamplona
9d75fb7074 Simplifies Annotations 2023-07-22 18:31:23 -04:00
Vitor Pamplona
5434f0fef7 Moves prepare controller to a try catch block 2023-07-22 18:29:41 -04:00
Vitor Pamplona
ed68ca5f26 Moves playback service startup to the IO Thread 2023-07-22 18:27:25 -04:00
Vitor Pamplona
4eea36678e Pauses the service only after the debug messages 2023-07-22 18:27:02 -04:00
Vitor Pamplona
15ce0b3e05 Uses OKHttp instead of Jsoup to download UrlPreview cards 2023-07-22 18:26:43 -04:00
Vitor Pamplona
eb73da1993 Activates Strict mode in debug 2023-07-22 18:26:12 -04:00
Vitor Pamplona
01ba21d303 Updates firebase version 2023-07-22 18:25:37 -04:00
Vitor Pamplona
442eecf916 Merge pull request #518 from ZsZolee/main
Adding additional Hungarian translation
2023-07-22 14:07:27 -04:00
Zoltan
231d02ac0d Adding additional Hungarian translation 2023-07-22 18:46:13 +02:00
Vitor Pamplona
43b011d3da v0.70.5 2023-07-21 21:00:32 -04:00
Vitor Pamplona
8a12cc3cce Releases the video player on Mutex when destroying the app. 2023-07-21 20:57:07 -04:00
Vitor Pamplona
7e03870c0b Releases the Mutex when stopping the feed. 2023-07-21 20:49:57 -04:00
Vitor Pamplona
a6f56416e3 Fixes lightning colors 2023-07-21 20:41:40 -04:00
Vitor Pamplona
efe7772135 Fixes color of the NIP05 address in the profile 2023-07-21 20:39:47 -04:00
Vitor Pamplona
15c96ce007 Removes the space in the video of a live activity mention 2023-07-21 17:48:09 -04:00
Vitor Pamplona
78d2a712c5 v0.70.4 2023-07-21 17:29:51 -04:00
Vitor Pamplona
2664292993 Moves wake lock to activate only when the video is actually playing 2023-07-21 17:08:56 -04:00
Vitor Pamplona
b9813c2c8f v0.70.3 2023-07-21 11:48:03 -04:00
Vitor Pamplona
0c00e6512f Modifier refactoring 2023-07-21 11:47:28 -04:00
Vitor Pamplona
cf31bfe5ea Adds a space when rendering inline images 2023-07-21 11:47:08 -04:00
Vitor Pamplona
5cdceb5194 Moves the loading of an Accounts backup contacts to the IO Thread 2023-07-21 11:46:31 -04:00
Vitor Pamplona
2c82e6c447 Adjustments to Modifiers in the Chatroom screen 2023-07-21 11:21:07 -04:00
Vitor Pamplona
f56f3b6105 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-07-21 11:16:55 -04:00
Vitor Pamplona
9420ddc5c7 Merge pull request #517 from greenart7c3/main
fix KeepPlayingButton color in light theme
2023-07-21 11:16:41 -04:00
greenart7c3
ade1d83817 fix KeepPlayingButton color in light theme 2023-07-21 12:11:23 -03:00
Vitor Pamplona
9a517380a0 Refactors ChatroomHeader compose 2023-07-21 11:02:16 -04:00
331 changed files with 26543 additions and 5083 deletions

View File

@@ -76,7 +76,7 @@ jobs:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
prerelease: true
# Google Play APK
- name: Upload Play APK Universal Asset
@@ -85,7 +85,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip

3
.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name generated
View File

@@ -1 +0,0 @@
Amethyst

View File

@@ -1,40 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

2
.idea/kotlinc.xml generated
View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.21" />
<option name="version" value="1.9.0" />
</component>
</project>

View File

@@ -75,10 +75,13 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [x] Moderated Communities (NIP-72)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [x] Private Messages and Small Groups (NIP-24)
- [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database

View File

@@ -1,33 +1,33 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
id 'org.jlleitschuh.gradle.ktlint' version "11.5.1"
id 'com.google.gms.google-services'
}
android {
namespace 'com.vitorpamplona.amethyst'
compileSdk 33
compileSdk 34
defaultConfig {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 259
versionName "0.70.2"
targetSdk 34
versionCode 278
versionName "0.74.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "de", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
}
buildTypes {
release {
// TODO: Make sure all of JSON parsers work when activating these.
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//signingConfig signingConfigs.debug
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), 'proguard-rules.pro'
minifyEnabled true
resValue "string", "app_name", "@string/app_name_release"
}
debug {
@@ -37,7 +37,7 @@ android {
}
}
flavorDimensions "channel"
flavorDimensions = ["channel"]
productFlavors {
play {
@@ -73,7 +73,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.7"
kotlinCompilerExtensionVersion "1.5.1"
}
packagingOptions {
@@ -87,6 +87,7 @@ android {
}
dependencies {
implementation project(path: ':quartz')
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
@@ -96,7 +97,7 @@ dependencies {
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation "androidx.navigation:navigation-compose:$nav_version"
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
@@ -108,31 +109,18 @@ dependencies {
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:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Zoomable images
implementation "net.engawapg.lib:zoomable:1.4.3"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
exclude group:'fr.acinq.secp256k1'
exclude module: 'guava'
exclude module: 'guava-testlib'
}
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
// HTML Parsing for Link Preview
implementation 'org.jsoup:jsoup:1.16.1'
// Encrypted Key Storage
@@ -156,11 +144,8 @@ dependencies {
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation 'com.google.zxing:core:3.5.2'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
@@ -175,9 +160,9 @@ dependencies {
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.media3:media3-exoplayer-hls:1.1.0'
implementation 'androidx.media3:media3-ui:1.1.0'
implementation 'androidx.media3:media3-session:1.1.0'
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-session:$media3_version"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -186,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
playImplementation platform('com.google.firebase:firebase-bom:32.2.2')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -195,19 +180,19 @@ dependencies {
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// GeoHash
implementation 'com.github.drfonfon:android-kotlin-geohash:1.0'
// Waveform visualizer
implementation 'com.github.lincollincol:compose-audiowaveform:1.1.1'
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.3.1'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
testImplementation 'io.mockk:mockk:1.13.7'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"

View File

@@ -5,33 +5,56 @@
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
# preserve the line number information for debugging stack traces.
-dontobfuscate
-keepattributes LocalVariableTable
-keepattributes LocalVariableTypeTable
-keepattributes *Annotation*
-keepattributes SourceFile
-keepattributes LineNumberTable
-keepattributes Signature
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes EnclosingMethod
-keepattributes MethodParameters
-keepparameternames
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class com.vitorpamplona.amethyst.service.model.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keepdirectories libs
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
# Keep all names
-keepnames class ** { *; }
# Keep All enums
-keep enum ** { *; }
# preserve access to native classses
-keep class fr.acinq.secp256k1.** { *; }
# JNA For Libsodium
-keep class com.goterl.lazysodium.** { *; }
# JNA also requires AWT, which Android does not have. So the classes are broken down to filter AWT out
-keep class com.sun.jna.ToNativeConverter { *; }
-keep class com.sun.jna.NativeMapped { *; }
-keep class com.sun.jna.CallbackReference { *; }
-keep class com.sun.jna.ptr.IntByReference { *; }
-keep class com.sun.jna.NativeLong { *; }
-keep class com.sun.jna.Structure { *; }
-keep class com.sun.jna.Structure$* { *; }
-keep class com.sun.jna.Native$ffi_callback { *; }
-keep class * implements com.sun.jna.Structure$* { *; }
-keep class * implements com.sun.jna.Native$* { *; }
-keep class com.sun.jna.Native {
private static com.sun.jna.NativeMapped fromNative(java.lang.Class, java.lang.Object);
private static com.sun.jna.NativeMapped fromNative(java.lang.reflect.Method, java.lang.Object);
private static java.lang.Class nativeType(java.lang.Class);
private static java.lang.Object toNative(com.sun.jna.ToNativeConverter, java.lang.Object);
private static java.lang.Object fromNative(com.sun.jna.FromNativeConverter, java.lang.Object, java.lang.reflect.Method);
}
# JSON parsing
-keep class com.vitorpamplona.quartz.crypto.** { *; }
-keep class com.vitorpamplona.quartz.encoders.** { *; }
-keep class com.vitorpamplona.quartz.events.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }

View File

@@ -1,113 +1,78 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.FileServer
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.delay
import com.vitorpamplona.quartz.crypto.KeyPair
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class ImageUploadTesting {
val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
@Test()
fun testImgurUpload() = runBlocking {
fun testBase(server: FileServer) {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
val countDownLatch = CountDownLatch(1)
var url: String? = null
var error: String? = null
ImageUploader.account = Account(
KeyPair()
)
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
server,
onSuccess = { newUrl, contentType ->
println("Uploaded to $url")
assertNotNull(url)
url = newUrl
countDownLatch.countDown()
},
onError = {
println("Failed to Upload")
fail("${it.message}")
error = it.message
countDownLatch.countDown()
}
)
delay(5000)
countDownLatch.await()
Assert.assertNull(error)
Assert.assertTrue(url?.startsWith("http") == true)
}
@Test()
fun testImgurUpload() = runBlocking {
testBase(ImgurServer())
}
@Test()
fun testNostrBuildUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrBuildServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
testBase(NostrBuildServer())
}
@Test()
fun testNostrImgUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrImgServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(1000)
testBase(NostrImgServer())
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrFilesDevServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
testBase(NostrFilesDevServer())
}
}

View File

@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -668,7 +668,7 @@ class RichTextParserTest {
fun testTextToParse() {
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
Assert.assertEquals(
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, ⚡41.7M, Nostr-Check.com, MR.Rabbit, Ancap.su,satscoinsv@getalby.com, miceliomad@miceliomad.github.io/nostr/, zapper.lol, smies.me, baller.hodl",
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
state.urlSet.joinToString(", ")
)
@@ -806,134 +806,101 @@ class RichTextParserTest {
"HashTag(#bitcoin,)",
"RegularText(1.7K,)",
"RegularText(109)",
"RegularText()",
"HashTag(#concussion,)",
"RegularText(1.1K,)",
"RegularText(25)",
"RegularText()",
"HashTag(#press,)",
"RegularText(0.9K,)",
"RegularText(65)",
"RegularText()",
"HashTag(#france,)",
"RegularText(492,)",
"RegularText(46)",
"RegularText()",
"HashTag(#presse,)",
"RegularText(480,)",
"RegularText(42)",
"RegularText()",
"HashTag(#covid19,)",
"RegularText(465,)",
"RegularText(65)",
"RegularText()",
"HashTag(#nostr,)",
"RegularText(414,)",
"RegularText(109)",
"RegularText()",
"HashTag(#zapathon,)",
"RegularText(386,)",
"RegularText(76)",
"RegularText()",
"HashTag(#rssfeed,)",
"RegularText(309,)",
"RegularText(53)",
"RegularText()",
"HashTag(#btc,)",
"RegularText(299,)",
"RegularText(109)",
"RegularText()",
"HashTag(#news,)",
"RegularText(294,)",
"RegularText(91)",
"RegularText()",
"HashTag(#zap,)",
"RegularText(283,)",
"RegularText(109)",
"RegularText()",
"HashTag(#linux,)",
"RegularText(253,)",
"RegularText(88)",
"RegularText()",
"HashTag(#respond,)",
"RegularText(246,)",
"RegularText(90)",
"RegularText()",
"HashTag(#kompost,)",
"RegularText(240,)",
"RegularText(31)",
"RegularText()",
"HashTag(#plebchain,)",
"RegularText(236,)",
"RegularText(109)",
"RegularText()",
"HashTag(#gardenaward,)",
"RegularText(236,)",
"RegularText(31)",
"RegularText()",
"HashTag(#start,)",
"RegularText(236,)",
"RegularText(31)",
"RegularText()",
"HashTag(#unicef,)",
"RegularText(233,)",
"RegularText(32)",
"RegularText()",
"HashTag(#coronavirus,)",
"RegularText(233,)",
"RegularText(33)",
"RegularText()",
"HashTag(#bew,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#balkon,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#terrasse,)",
"RegularText(229,)",
"RegularText(31)",
"RegularText()",
"HashTag(#braininjuryawareness,)",
"RegularText(229,)",
"RegularText(24)",
"RegularText()",
"HashTag(#garten,)",
"RegularText(220,)",
"RegularText(21)",
"RegularText()",
"HashTag(#smart,)",
"RegularText(220,)",
"RegularText(21)",
"RegularText()",
"HashTag(#nsfw,)",
"RegularText(211,)",
"RegularText(85)",
"RegularText()",
"HashTag(#protoncalendar,)",
"RegularText(206,)",
"RegularText(31)",
"RegularText()",
"HashTag(#stacksats,)",
"RegularText(195,)",
"RegularText(99)",
"RegularText()",
"HashTag(#nokyc,)",
"RegularText(179,)",
"RegularText(98)",
"RegularText()",
"RegularText()",
"RegularText(Emoji sentiment today)",
"RegularText()",
"RegularText(⚡ (1.6K) 👉 (1.4K) 🇪🇺 (1.2K) 🫂 (1.2K) 🇺🇸 (1.1K) 💜 (875) 🧠 (858) 😂 (830) 🔥 (690) 🤣 (566) 🤙 (525) ☕ (444) 👇 (443) 🙌🏻 (425) ☀ (307) 😎 (305) 🥳 (301) 🤔 (276) 🌻 (270) 🧡 (270) 🥇 (269) 🗓 (269) 🙏 (268) 🏆 (267) 🌱 (264) 📰 (230) 🏉 (221) 😭 (220) 💰 (219) 🔗 (209) 👀 (201) 😅 (199) ✨ (193) 🇷🇺 (182) 💪 (167) ✅ (164) 💤 (163) 🐶 (151) 🇨🇭 (141) 📝 (137) 😁 (136) 🌞 (136) 🐾 (136) ❤ (132) 💻 (126) 🚀 (125) 👍 (125) 🇧🇷 (125) 😊 (121) 📚 (120) ➡ (120) 👏 (118) 🎉 (117) 🎮 (115) 🤷 (113) 👋 (112) 💃 (108) 🕺🏻 (106) 💡 (104) 🚨 (99) 😆 (97) 💯 (95) ⚠ (92) 📢 (92) 🤗 (89) 😴 (87) 🔁 (83) 🐰 (81) 😀 (79) 🎟 (78) ⛏ (78) 🐦 (76) 💸 (76) ✌🏻 (75) 🤍 (73) 🇬🇧 (73) 🌽 (70) 🤡 (69) 🤮 (69) ❗ (66) 🤝 (65) 😉 (65) 🙇 (65) 🍻 (64) 🌍 (64) 💕 (63) 🌸 (62) 💬 (61) ☺ (61) 🇦🇷 (59) 🇮🇩 (57) 😳 (57) 😄 (57) 🎶 (57) 🥷🏻 (56) 🎵 (56) 😃 (56) 🔍 (55) 💥 (55) 🎲 (54) ✍ (54) 🕒 (53) ⬇ (53) 💙 (51) 🔒 (50) 📈 (50) 🪙 (50) 🌧 (50) 🥰 (50) 🕸 (50) 🌐 (50) 💭 (49) 🌙 (49) 😍 (49) 📱 (48) 🌟 (48) 🤩 (48) 💔 (47) 🔌 (47) 😋 (47) 🎖 (47) 🍣 (46) 📷 (46) 💼 (45) ⭐ (45) 🥔 (45) 🥺 (45) 👌 (44) 👷🏼 (43) 😱 (43) 📅 (43) 🤖 (43) 📸 (42) 📊 (42) 🦑 (40) 💵 (40) 🤦 (39) ❣ (38) 💎 (38) 🖤 (38) 📺 (37) 🇵🇱 (37) 🇯🇵 (36) 🔧 (36) 🤘 (36) 💖 (36) ‼ (35) 😢 (35) 😺 (34) 🔊 (34) 😏 (34) 🇸🇰 (34) 🏃 (34) 👩‍👧 (34) ⏰ (33) 👨‍💻 (33) 👑 (33) 👥 (32) 🖥 (32) 💨 (32) 💗 (31) 🇲🇽 (31) 📖 (31) 🚫 (31) 👊🏻 (31) 😡 (31) 🌎 (31) 👁 (30) 🗞 (30) 🍀 (30) 🍽 (29) 🐸 (29) 🥚 (29) 💩 (29) ✊🏾 (29) 😮 (29) 🌡 (29) 🙃 (28) 🔔 (28) 🇻🇪 (28) 💦 (28) 🎯 (28) 🎨 (28) 🍛 (28) 🖼 (27) ☝🏻 (27) 🛑 (27) 🙄 (27) 🧑🏻‍🤝‍🧑🏽 (27) 🌈 (27) 🥂 (26) 🇫🇮 (26) 🎥 (26) 😬 (26) 🥲 (25) 🦾 (24) 🤜 (24) 🙂 (24) 🖕 (24) 😩 (24) )",
"RegularText()",
"RegularText(Zap economy)",
"RegularText()",
"Link(⚡41.7M)",
"RegularText(sats)",
"RegularText((₿0.417))",
"RegularText()",
"RegularText(⚡41.7M sats (₿0.417) )",
"RegularText(1,816 zappers & 920 zapped (unique pubkeys))",
"RegularText(🌩️ 33,248 zaps, 1,253 sats per zap (avg))",
"RegularText()",
@@ -2248,7 +2215,7 @@ class RichTextParserTest {
"HashTag(#214)",
"RegularText(2%)",
"RegularText(Satscoinsv,)",
"Link(⚡satscoinsv@getalby.com)",
"RegularText(⚡satscoinsv@getalby.com)",
"RegularText(-)",
"RegularText(80db64657ea0358c5332c5cca01565eeddd4b8799688b1c46d3cb2d7c966671f)",
"HashTag(#215)",
@@ -2458,7 +2425,7 @@ class RichTextParserTest {
"HashTag(#249)",
"RegularText(2%)",
"RegularText(micmad,)",
"Link(miceliomad@miceliomad.github.io/nostr/)",
"RegularText(miceliomad@miceliomad.github.io/nostr/)",
"RegularText(-)",
"RegularText(cd806edcf8ff40ea94fa574ea9cd97da16e5beb2b85aac6e1d648b8388504343)",
"HashTag(#250)",
@@ -3805,7 +3772,7 @@ class RichTextParserTest {
"HashTag(#471)",
"RegularText(1%)",
"RegularText(leonwankum,)",
"SchemelessUrl(@leonawankum@BitcoinNostr.com)",
"RegularText(@leonawankum@BitcoinNostr.com)",
"RegularText()",
"RegularText(-)",
"RegularText(652d58acafa105af8475c0fe8029a52e7ddbc337b2bd9c98bb17a111dc4cde60)",
@@ -4098,7 +4065,6 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
"RegularText(here:)",
"Link(https://lnshort.it/live-stream-embeds/)",
"RegularText()",
"RegularText()",
"Image(https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg)"
)
@@ -4110,6 +4076,57 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
}
}
@Test
fun testNewLineAfterImage() {
val text = "Thats it ! Thats the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
val state = RichTextParser().parseText(text, ImmutableListOfLists())
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it)",
"RegularText(!)",
"RegularText(Thats)",
"RegularText(the)",
"HashTag(#note)",
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
@Test
fun testSapceAfterImage() {
val text = "Thats it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thats the #note"
val state = RichTextParser().parseText(text, ImmutableListOfLists())
printStateForDebug(state)
val expectedResult = listOf<String>(
"RegularText(Thats)",
"RegularText(it!)",
"Image(https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg)",
"RegularText(Thats)",
"RegularText(the)",
"HashTag(#note)"
)
state.paragraphs.map { it.words }.flatten().forEachIndexed { index, seg ->
Assert.assertEquals(
expectedResult[index],
"${seg.javaClass.simpleName.replace("Segment", "")}(${seg.segmentText})"
)
}
}
private fun printStateForDebug(state: RichTextViewerState) {
state.paragraphs.forEachIndexed { index, paragraph ->
paragraph.words.forEach { seg ->

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.UserMetadata
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -29,7 +29,10 @@ class UrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
@@ -63,7 +66,10 @@ class UrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
val user = LocalCache.getOrCreateUser(
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey()
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"

View File

@@ -99,4 +99,13 @@ class TranslationsTest {
"en"
)
}
@Test
fun testEmoji() {
assertTranslateContains(
"https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg",
"\uD83E\uDD23 https://cdn.nostr.build/i/df3783dcdf7dd289ba02ba538dc039c8fe1d4db055e580b81604ed88c6af4ee0.jpg ",
"pt"
)
}
}

View File

@@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@Composable
fun TranslatableRichTextViewer(

View File

@@ -34,6 +34,12 @@
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- This notification permission is needed for some phones -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Adds Geohash to posts if active -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

View File

@@ -1,11 +1,38 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
class Amethyst : Application() {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
override fun onCreate() {
super.onCreate()
instance = this
VideoCache.initFileCache(instance)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectAll()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
VmPolicy.Builder()
.detectActivityLeaks()
.detectFileUriExposure()
.detectContentUriWithoutPermission()
.detectLeakedClosableObjects()
.detectLeakedRegistrationObjects()
.detectLeakedSqlLiteObjects()
.penaltyLog()
// .penaltyDeath()
.build()
)
}
}
companion object {

View File

@@ -4,25 +4,25 @@ import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ConnectivityType
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.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.toHex
import nostr.postr.toNpub
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
import java.io.File
import java.util.Locale
@@ -62,6 +62,7 @@ private object PrefKeys {
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_24_WARNING_DIALOG = "hide_nip24_warning_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
@@ -76,8 +77,6 @@ private object PrefKeys {
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
@@ -209,32 +208,33 @@ object LocalPreferences {
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, account.hideNIP24WarningDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
putString(PrefKeys.LAST_READ_PER_ROUTE, Event.mapper.writeValueAsString(account.lastReadPerRoute))
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
@@ -245,21 +245,21 @@ object LocalPreferences {
val globalPrefs = encryptedPreferences()
globalPrefs.edit().apply {
if (account.settings.automaticallyShowImages == null) {
if (account.settings.automaticallyShowImages.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages.prefCode!!)
}
if (account.settings.automaticallyStartPlayback == null) {
if (account.settings.automaticallyStartPlayback.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback.prefCode!!)
}
if (account.settings.automaticallyShowUrlPreview == null) {
if (account.settings.automaticallyShowUrlPreview.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
@@ -298,10 +298,10 @@ object LocalPreferences {
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val localRelays = getString(PrefKeys.RELAYS, "[]")?.let {
println("LocalRelays: $it")
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
} ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
@@ -310,29 +310,25 @@ object LocalPreferences {
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
Event.mapper.readValue<List<Long>?>(it)
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
val reactionChoices = gson.fromJson<List<String>>(
getString(PrefKeys.REACTION_CHOICES, "[]"),
object : TypeToken<List<String>>() {}.type
).ifEmpty { listOf("+") } ?: listOf("+")
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
Event.mapper.readValue<List<String>?>(it)
}?.ifEmpty { listOf("+") } ?: listOf("+")
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
} ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.NOSTR_BUILD
val defaultFileServer = getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
ServersAvailable.values().firstOrNull() { it.name == serverName }
} ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -341,8 +337,7 @@ object LocalPreferences {
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java)
.getRefinedEvent(true) as ContactListEvent
Event.fromJson(it) as ContactListEvent?
}
} catch (e: Throwable) {
e.printStackTrace()
@@ -351,10 +346,7 @@ object LocalPreferences {
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
@@ -363,6 +355,7 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP24WarningDialog = getBoolean(PrefKeys.HIDE_NIP_24_WARNING_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
@@ -377,10 +370,7 @@ object LocalPreferences {
val lastReadPerRoute = try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, Long>>() {}.type
) as Map<String, Long>
Event.mapper.readValue<Map<String, Long>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
@@ -390,27 +380,27 @@ object LocalPreferences {
val settings = Settings()
encryptedPreferences().apply {
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
} else {
null
ConnectivityType.ALWAYS
}
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
} else {
null
ConnectivityType.ALWAYS
}
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
} else {
null
ConnectivityType.ALWAYS
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
val a = Account(
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
@@ -429,6 +419,7 @@ object LocalPreferences {
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP24WarningDialog = hideNIP24WarningDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,

View File

@@ -12,6 +12,7 @@ import androidx.media3.common.Player.STATE_READY
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.vitorpamplona.amethyst.ui.MainActivity
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -66,7 +67,6 @@ class MultiPlayerPlaybackManager(
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
setWakeMode(C.WAKE_MODE_NETWORK)
}
val mediaSession = MediaSession.Builder(context, player).run {
@@ -80,8 +80,10 @@ class MultiPlayerPlaybackManager(
player.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) {
player.setWakeMode(C.WAKE_MODE_NETWORK)
playingMap.put(id, mediaSession)
} else {
player.setWakeMode(C.WAKE_MODE_NONE)
cache.put(id, mediaSession)
playingMap.remove(id, mediaSession)
}
@@ -115,6 +117,7 @@ class MultiPlayerPlaybackManager(
return mediaSession
}
@OptIn(DelicateCoroutinesApi::class)
fun releaseAppPlayers() {
GlobalScope.launch(Dispatchers.Main) {
cache.evictAll()

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.common.util.concurrent.MoreExecutors
@@ -16,25 +17,32 @@ object PlaybackClientController {
context: Context,
onReady: (MediaController) -> Unit
) {
// creating a bundle object
// creating a bundle object
val bundle = Bundle()
bundle.putString("id", controllerID)
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
try {
// creating a bundle object
// creating a bundle object
val bundle = Bundle()
bundle.putString("id", controllerID)
bundle.putString("uri", videoUri)
bundle.putString("callbackUri", callbackUri)
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val controllerFuture = MediaController
.Builder(context, sessionTokenLocal)
.setConnectionHints(bundle)
.buildAsync()
val sessionTokenLocal = SessionToken(context, ComponentName(context, PlaybackService::class.java))
val controllerFuture = MediaController
.Builder(context, sessionTokenLocal)
.setConnectionHints(bundle)
.buildAsync()
controllerFuture.addListener(
{
val controller = controllerFuture.get()
onReady(controller)
},
MoreExecutors.directExecutor()
)
controllerFuture.addListener(
{
try {
onReady(controllerFuture.get())
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
},
MoreExecutors.directExecutor()
)
} catch (e: Exception) {
Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e)
}
}
}

View File

@@ -6,7 +6,6 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.service.HttpClient
@@ -24,7 +23,31 @@ class PlaybackService : MediaSessionService() {
}
fun newProgressiveDataSource(): MediaSource.Factory {
return ProgressiveMediaSource.Factory(VideoCache.get(applicationContext, HttpClient.getHttpClient()))
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
}
fun lazyHlsDS(): MultiPlayerPlaybackManager {
managerHls?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerHls = newInstance
return newInstance
}
fun lazyProgressiveDS(): MultiPlayerPlaybackManager {
managerProgressive?.let { return it }
val newInstance = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerProgressive = newInstance
return newInstance
}
fun lazyLocalDS(): MultiPlayerPlaybackManager {
managerLocal?.let { return it }
val newInstance = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
managerLocal = newInstance
return newInstance
}
// Create your Player and MediaSession in the onCreate lifecycle event
@@ -32,28 +55,24 @@ class PlaybackService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
}
private fun onProxyUpdated() {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
managerLocal = MultiPlayerPlaybackManager(cachedPositions = videoViewedPositionCache)
// Stop all videos and recreates all managers when the proxy changes.
HttpClient.proxyChangeListeners.add {
val toDestroyHls = managerHls
val toDestroyProgressive = managerProgressive
managerHls = MultiPlayerPlaybackManager(newHslDataSource(), videoViewedPositionCache)
managerProgressive = MultiPlayerPlaybackManager(newProgressiveDataSource(), videoViewedPositionCache)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
setMediaNotificationProvider(
DefaultMediaNotificationProvider.Builder(applicationContext).build()
)
toDestroyHls?.releaseAppPlayers()
toDestroyProgressive?.releaseAppPlayers()
}
override fun onDestroy() {
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
managerHls?.releaseAppPlayers()
managerLocal?.releaseAppPlayers()
managerProgressive?.releaseAppPlayers()
@@ -63,11 +82,11 @@ class PlaybackService : MediaSessionService() {
fun getAppropriateMediaSessionManager(fileName: String): MultiPlayerPlaybackManager? {
return if (fileName.startsWith("file")) {
managerLocal
lazyLocalDS()
} else if (fileName.endsWith("m3u8")) {
managerHls
lazyHlsDS()
} else {
managerProgressive
lazyProgressiveDS()
}
}

View File

@@ -8,6 +8,8 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
@@ -17,6 +19,7 @@ import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrGeohashDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -28,6 +31,7 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import java.io.File
object ServiceManager {
private var account: Account? = null
@@ -54,7 +58,8 @@ object ServiceManager {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
}.logger(DebugLogger())
.diskCache { SingletonDiskCache.get(context.applicationContext) }
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
@@ -97,6 +102,7 @@ object ServiceManager {
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrGeohashDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
@@ -115,7 +121,32 @@ object ServiceManager {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
LocalCache.pruneRepliesAndReactions(it)
}
}
}
object SingletonDiskCache {
private const val DIRECTORY = "image_cache"
private var instance: DiskCache? = null
@Synchronized
fun get(context: Context): DiskCache {
return instance ?: run {
// Create the singleton disk cache instance.
DiskCache.Builder()
.directory(context.safeCacheDir.resolve(DIRECTORY))
.maxSizePercent(0.2)
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
.build()
.also { instance = it }
}
}
}
internal val Context.safeCacheDir: File
get() {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}

View File

@@ -8,6 +8,7 @@ import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import okhttp3.OkHttpClient
import java.io.File
@UnstableApi object VideoCache {
@@ -21,17 +22,14 @@ import okhttp3.OkHttpClient
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private fun init(context: Context, client: OkHttpClient) {
fun initFileCache(context: Context) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
context.cacheDir,
File(context.cacheDir, "exoplayer"),
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
renewCacheFactory(client)
}
// This method should be called when proxy setting changes.
@@ -46,9 +44,12 @@ import okhttp3.OkHttpClient
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
if (!this::simpleCache.isInitialized) {
init(context, client)
initFileCache(context)
}
// Renews the factory because OkHttpMight have changed.
renewCacheFactory(client)
return cacheDataSourceFactory
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,11 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
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.events.Event
import kotlinx.coroutines.Dispatchers
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)

View File

@@ -4,12 +4,14 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap

View File

@@ -1,119 +0,0 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import nostr.postr.Bech32
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
import nostr.postr.toNpub
/** Makes the distinction between String and Hex **/
typealias HexKey = String
typealias NPubKey = String
typealias NoteId = String
fun NPubKey.toDisplayKey(): String {
return this.toShortenHex()
}
fun NoteId.toDisplayId(): String {
return this.toShortenHex()
}
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toHexKey(): HexKey {
return toHex()
}
fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this)
}
fun HexKey.toDisplayHexKey(): String {
return this.toShortenHex()
}
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key)
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex
if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key).toHexKey()
}
} catch (e: Exception) {
null
}
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
if (key.startsWith("nostr:", true)) {
key = key.substring("nostr:".length)
}
key = key.removePrefix("@")
if (key.length < 63) {
return null
}
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
// Converts to npub
val pubkey = Nip19.uriToRoute(Persona(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
val noteId = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}

View File

@@ -3,13 +3,19 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
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.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -218,6 +224,26 @@ object LocalCache {
}
}
private fun consume(event: AdvertisedRelayListEvent) {
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)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -1025,6 +1051,23 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: AudioHeaderEvent, 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: FileHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -1086,7 +1129,7 @@ object LocalCache {
}
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
val cachePath = File(Amethyst.instance.applicationContext.cacheDir, "NIP95")
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
@@ -1110,6 +1153,79 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: ChatMessageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val recipientsHex = event.recipientsPubKey().plus(event.pubKey).toSet()
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
if (recipients.isNotEmpty()) {
recipients.forEach {
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
val authorGroup = if (groupMinusRecipient.isEmpty()) {
// note to self
ChatroomKey(persistentSetOf(it.pubkeyHex))
} else {
ChatroomKey(groupMinusRecipient.toImmutableSet())
}
it.addMessage(authorGroup, note)
}
}
refreshObservers(note)
}
private fun consume(event: SealedGossipEvent, 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: GiftWrapEvent, 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: LnZapPaymentRequestEvent) {
// Does nothing without a response callback.
}
@@ -1185,15 +1301,20 @@ object LocalCache {
}
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true) ||
it.idNote().startsWith(text, true)
)
} + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
(it.event !is GenericRepostEvent && it.event !is RepostEvent && it.event !is CommunityPostApprovalEvent && it.event !is ReactionEvent && it.event !is GiftWrapEvent) &&
(
it.event?.content()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
)
}
}
@@ -1222,6 +1343,10 @@ object LocalCache {
it.value.clearLive()
}
addressables.forEach {
it.value.clearLive()
}
users.forEach {
it.value.clearLive()
}
@@ -1233,6 +1358,8 @@ object LocalCache {
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
@@ -1241,17 +1368,84 @@ object LocalCache {
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
}
}
users.forEach { userPair ->
userPair.value.privateChatrooms.values.map {
val toBeRemoved = it.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept")
}
}
}
}
fun pruneRepliesAndReactions(account: Account) {
checkNotInMainThread()
val user = account.userProfile()
val toBeRemoved = notes.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.value.liveSet?.isInUse() != true && // don't delete if observing.
it.value.author != user && // don't delete if it is the logged in account
it.value.event?.isTaggedUser(user.pubkeyHex) != true // don't delete if it's a notification to the logged in user
}.values
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
notes.remove(it.idHex)
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.clearEOSE() // allows reloading of these events
}
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
fun pruneHiddenMessages(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved = account.hiddenUsers.map { userHex ->
(
notes.values.filter {
@@ -1273,11 +1467,30 @@ object LocalCache {
}
notes.remove(it.idHex)
childrenToBeRemoved.addAll(it.removeAllChildNotes())
}
removeChildrenOf(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note ->
if (note.event is LnZapEvent) {
(note.event as LnZapEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is LnZapRequestEvent) {
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull { getUserIfExists(it)?.removeZap(note) }
}
if (note.event is ReportEvent) {
(note.event as ReportEvent).reportedAuthor().mapNotNull { getUserIfExists(it.key)?.removeZap(note) }
}
notes.remove(note.idHex)
}
}
fun pruneContactLists(userAccount: Account) {
checkNotInMainThread()
@@ -1300,21 +1513,35 @@ object LocalCache {
}
fun verifyAndConsume(event: Event, relay: Relay?) {
if (justVerify(event)) {
justConsume(event, relay)
}
}
fun justVerify(event: Event): Boolean {
checkNotInMainThread()
if (!event.hasValidSignature()) {
return if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
false
} else {
true
}
}
fun justConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
try {
when (event) {
is AdvertisedRelayListEvent -> consume(event)
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AudioHeaderEvent -> consume(event, relay)
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
@@ -1325,6 +1552,7 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ChatMessageEvent -> consume(event, relay)
is ClassifiedsEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
@@ -1337,10 +1565,12 @@ object LocalCache {
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is SealedGossipEvent -> consume(event, relay)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is GiftWrapEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)

View File

@@ -0,0 +1,5 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)

View File

@@ -6,24 +6,24 @@ import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
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.toNote
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
@@ -50,20 +50,20 @@ open class Note(val idHex: String) {
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
var replies = setOf<Note>()
var replies = listOf<Note>()
private set
var reactions = mapOf<String, Set<Note>>()
var reactions = mapOf<String, List<Note>>()
private set
var boosts = setOf<Note>()
var boosts = listOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
var reports = mapOf<User, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
var relays = listOf<String>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
@@ -72,7 +72,22 @@ open class Note(val idHex: String) {
open fun idNote() = id().toNote()
open fun toNEvent(): String {
return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
val myEvent = event
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull())
}
}
fun toNostrUri(): String {
@@ -103,11 +118,13 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.replyTo = replyTo
if (this.event?.id() != event.id()) {
this.event = event
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
liveSet?.metadata?.invalidateData()
}
}
fun formattedDateTime(timestamp: Long): String {
@@ -118,17 +135,26 @@ open class Note(val idHex: String) {
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
fun replyLevelSignature(
eventsToConsider: Set<Note>,
cachedSignatures: MutableMap<Note, String>
): String {
val replyTo = replyTo
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
return replyTo
.map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(createdAt() ?: 0) + ";"
val mySignature = (
replyTo
.filter { it in eventsToConsider } // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it] ?: it.replyLevelSignature(eventsToConsider, cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.length }?.removeSuffix(";") ?: ""
) + "/" + formattedDateTime(createdAt() ?: 0) + ";"
cachedSignatures[this] = mySignature
return mySignature
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
@@ -150,27 +176,63 @@ open class Note(val idHex: String) {
}
fun removeReply(note: Note) {
replies = replies - note
liveSet?.replies?.invalidateData()
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
}
fun removeBoost(note: Note) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
}
}
fun removeAllChildNotes(): List<Note> {
val toBeRemoved = replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull()
replies = listOf<Note>()
reactions = mapOf<String, List<Note>>()
boosts = listOf<Note>()
reports = mapOf<User, List<Note>>()
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
relays = listOf<String>()
lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData()
liveSet?.reactions?.invalidateData()
liveSet?.boosts?.invalidateData()
liveSet?.reports?.invalidateData()
liveSet?.zaps?.invalidateData()
return toBeRemoved
}
fun removeReaction(note: Note) {
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
if (note in it) {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
}
}
@@ -276,7 +338,7 @@ open class Note(val idHex: String) {
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note))
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
@@ -288,7 +350,7 @@ open class Note(val idHex: String) {
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
@@ -381,8 +443,8 @@ open class Note(val idHex: String) {
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
fun hasReportsBy(user: User): Boolean {
return reports[user]?.isNotEmpty() ?: false
}
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
@@ -546,13 +608,17 @@ open class Note(val idHex: String) {
}
replyTo = null
replies = emptySet()
replies = emptyList()
reactions = emptyMap()
boosts = emptySet()
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
@@ -564,6 +630,7 @@ open class Note(val idHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -605,13 +672,29 @@ class NoteLiveSet(u: Note) {
relays.hasObservers() ||
zaps.hasObservers()
}
fun destroy() {
metadata.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
reports.destroy()
relays.destroy()
zaps.destroy()
}
}
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.encoders.HexKey
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
baseNote.author?.let { author ->

View File

@@ -1,15 +1,12 @@
package com.vitorpamplona.amethyst.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@Stable
class RelayInformation(
val id: String?,
val name: String?,
val description: String?,
val pubkey: String?,
@@ -27,113 +24,26 @@ class RelayInformation(
val fees: RelayInformationFees?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
fun fromJson(json: String): RelayInformation = mapper.readValue(json, RelayInformation::class.java)
}
}
@Stable
class RelayInformationFee(
val amount: Int?,
val unit: String?,
val period: Int?,
val kinds: List<Int>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
}
}
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
override fun serialize(
src: RelayInformationFee,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("amount", src.amount)
addProperty("unit", src.unit)
addProperty("period", src.period)
add(
"kinds",
JsonArray().also { kinds ->
src.kinds?.forEach { kind ->
kinds.add(
kind
)
}
}
)
}
}
}
)
class RelayInformationFees(
val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.create()
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
}
}
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
override fun serialize(
src: RelayInformationFees,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
add(
"admission",
JsonArray().also { admissions ->
src.admission?.forEach { admission ->
admissions.add(
admission.toString()
)
}
}
)
add(
"publication",
JsonArray().also { publications ->
src.publication?.forEach { publication ->
publications.add(
publication.toString()
)
}
}
)
add(
"subscription",
JsonArray().also { subscriptions ->
src.subscription?.forEach { subscription ->
subscriptions.add(
subscription.toString()
)
}
}
)
}
}
}
val publication: List<RelayInformationFee>?,
val retention: List<RelayInformationFee>?
)
class RelayInformationLimitation(
val max_message_length: Int?,
@@ -147,104 +57,4 @@ class RelayInformationLimitation(
val min_pow_difficulty: Int?,
val auth_required: Boolean?,
val payment_required: Boolean?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.create()
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
}
}
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
override fun serialize(
src: RelayInformationLimitation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("max_message_length", src.max_message_length)
addProperty("max_subscriptions", src.max_subscriptions)
addProperty("max_filters", src.max_filters)
addProperty("max_limit", src.max_limit)
addProperty("max_subid_length", src.max_subid_length)
addProperty("min_prefix", src.min_prefix)
addProperty("max_event_tags", src.max_event_tags)
addProperty("max_content_length", src.max_content_length)
addProperty("min_pow_difficulty", src.min_pow_difficulty)
addProperty("auth_required", src.auth_required)
addProperty("payment_required", src.payment_required)
}
}
}
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
override fun serialize(
src: RelayInformation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("name", src.name)
addProperty("description", src.description)
addProperty("pubkey", src.pubkey)
addProperty("contact", src.contact)
add(
"supported_nip_extensions",
JsonArray().also { supported_nip_extensions ->
src.supported_nip_extensions?.forEach { nip ->
supported_nip_extensions.add(
nip
)
}
}
)
add(
"supported_nips",
JsonArray().also { supported_nips ->
src.supported_nips?.forEach { nip ->
supported_nips.add(
nip
)
}
}
)
addProperty("software", src.software)
addProperty("version", src.version)
add(
"relay_countries",
JsonArray().also { relay_countries ->
src.relay_countries?.forEach { country ->
relay_countries.add(
country
)
}
}
)
add(
"language_tags",
JsonArray().also { language_tags ->
src.language_tags?.forEach { language_tag ->
language_tags.add(
language_tag
)
}
}
)
add(
"tags",
JsonArray().also { tags ->
src.tags?.forEach { tag ->
tags.add(
tag
)
}
}
)
addProperty("posting_policy", src.posting_policy)
addProperty("payments_url", src.payments_url)
}
}
}
)

View File

@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.model
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}

View File

@@ -1,11 +1,40 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
@Stable
class Settings(
var automaticallyShowImages: Boolean? = null,
var automaticallyStartPlayback: Boolean? = null,
var preferredLanguage: String? = null,
var automaticallyShowUrlPreview: Boolean? = null
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS
)
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never)
}
fun parseConnectivityType(code: Boolean?): ConnectivityType {
return when (code) {
ConnectivityType.ALWAYS.prefCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.prefCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.prefCode -> ConnectivityType.NEVER
else -> {
ConnectivityType.ALWAYS
}
}
}
fun parseConnectivityType(screenCode: Int): ConnectivityType {
return when (screenCode) {
ConnectivityType.ALWAYS.screenCode -> ConnectivityType.ALWAYS
ConnectivityType.WIFI_ONLY.screenCode -> ConnectivityType.WIFI_ONLY
ConnectivityType.NEVER.screenCode -> ConnectivityType.NEVER
else -> {
ConnectivityType.ALWAYS
}
}
}

View File

@@ -1,9 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlin.time.ExperimentalTime
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
class ThreadAssembler {
@@ -16,9 +15,14 @@ class ThreadAssembler {
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
if (markedAsRoot != null) {
// Check to ssee if there is an error in the tag and the root has replies
if (LocalCache.getNoteIfExists(markedAsRoot)?.replyTo?.isEmpty() == true) {
return LocalCache.checkGetOrCreateNote(markedAsRoot)
}
}
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
val hasNoReplyTo = note.replyTo?.reversed()?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// recursive
@@ -37,7 +41,6 @@ class ThreadAssembler {
return null
}
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()

View File

@@ -1,13 +0,0 @@
package com.vitorpamplona.amethyst.model
object TimeUtils {
const val fiveMinutes = 60 * 5
const val oneHour = 60 * 60
const val oneDay = 24 * 60 * 60
fun now() = System.currentTimeMillis() / 1000
fun fiveMinutesAgo() = now() - fiveMinutes
fun oneHourAgo() = now() - oneHour
fun oneDayAgo() = now() - oneDay
fun eightHoursAgo() = now() - (oneHour * 8)
}

View File

@@ -2,33 +2,35 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Stable
object UrlCachedPreviewer {
var cache = LruCache<String, UrlPreviewState>(100)
private set
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
checkNotInMainThread()
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit
) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return
return@withContext
}
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
override suspend fun onComplete(urlInfo: UrlInfoItem) = withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
return@withContext
}
}
@@ -42,10 +44,10 @@ object UrlCachedPreviewer {
onReady(state)
}
override fun onFailed(throwable: Throwable) {
override suspend fun onFailed(throwable: Throwable) = withContext(Dispatchers.IO) {
cache[url]?.let {
onReady(it)
return
return@withContext
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")

View File

@@ -5,21 +5,25 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import nostr.postr.Bech32
import nostr.postr.toNpub
import java.math.BigDecimal
import java.util.regex.Pattern
@@ -43,7 +47,7 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<User, Chatroom>()
var privateChatrooms = mapOf<ChatroomKey, Chatroom>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
@@ -55,6 +59,21 @@ class User(val pubkeyHex: String) {
override fun toString(): String = pubkeyHex
fun toBestShortFirstName(): String {
val fullName = bestDisplayName() ?: bestUsername() ?: return pubkeyDisplayHex()
val names = fullName.split(' ')
val firstName = if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
@@ -137,6 +156,16 @@ class User(val pubkeyHex: String) {
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
@@ -164,18 +193,31 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
return privateChatrooms[key] ?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
return getOrCreatePrivateChatroom(key)
}
private fun getOrCreatePrivateChatroom(key: ChatroomKey): Chatroom {
return privateChatrooms[key] ?: getOrCreatePrivateChatroomSync(key)
}
fun addMessage(room: ChatroomKey, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
fun addMessage(user: User, msg: Note) {
@@ -186,6 +228,10 @@ class User(val pubkeyHex: String) {
}
}
fun createChatroom(withKey: ChatroomKey) {
getOrCreatePrivateChatroom(withKey)
}
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
@@ -249,6 +295,12 @@ class User(val pubkeyHex: String) {
} ?: false
}
fun isFollowingGeohashCached(geoTag: String): Boolean {
return latestContactList?.verifiedFollowGeohashSet?.let {
return geoTag.lowercase() in it
} ?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
@@ -277,6 +329,10 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowingGeohashSet(): Set<HexKey> {
return latestContactList?.verifiedFollowGeohashSet ?: emptySet()
}
fun cachedFollowingCommunitiesSet(): Set<HexKey> {
return latestContactList?.verifiedFollowCommunitySet ?: emptySet()
}
@@ -289,8 +345,8 @@ class User(val pubkeyHex: String) {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
fun hasSentMessagesTo(key: ChatroomKey?): Boolean {
val messagesToUser = privateChatrooms[key] ?: return false
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
@@ -316,6 +372,7 @@ class User(val pubkeyHex: String) {
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
@@ -344,6 +401,18 @@ class UserLiveSet(u: User) {
zaps.hasObservers() ||
bookmarks.hasObservers()
}
fun destroy() {
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
metadata.destroy()
zaps.destroy()
bookmarks.destroy()
}
}
@Immutable
@@ -353,8 +422,11 @@ data class RelayInfo(
var counter: Long
)
@Stable
class Chatroom() {
var roomMessages: Set<Note> = setOf()
var subject: String? = null
var subjectCreatedAt: Long? = null
@Synchronized
fun addMessageSync(msg: Note) {
@@ -362,6 +434,13 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
val newSubject = msg.event?.subject()
if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) {
subject = newSubject
subjectCreatedAt = msg.createdAt()
}
}
}
@@ -371,66 +450,32 @@ class Chatroom() {
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let {
subject = it.event?.subject()
subjectCreatedAt = it.createdAt()
}
}
}
}
@Stable
class UserMetadata {
var name: String? = null
var username: String? = null
var display_name: String? = null
var displayName: String? = null
var picture: String? = null
var banner: String? = null
var website: String? = null
var about: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
var publish: String? = null
var iris: String? = null
var main_relay: String? = null
var twitter: String? = null
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
fun senderIntersects(keySet: Set<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.contains(prefix, true) }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
// Recent messages, keep last 100
sorted.take(100).toSet()
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false }
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
return toRemove
}
}
@@ -438,7 +483,12 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
if (!hasObservers()) return
checkNotInMainThread()
bundler.invalidate() {

View File

@@ -6,7 +6,6 @@ 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.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
@@ -15,6 +14,7 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
@@ -54,11 +54,13 @@ object CachedRichTextParser {
// 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}])*\\/?)(.*)")
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 parseText(
content: String,
@@ -75,8 +77,7 @@ class RichTextParser() {
} else if (it.originalUrl.contains("")) {
null
} else {
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(it.originalUrl)) {
if (HTTPRegex.matches(it.originalUrl)) {
it.originalUrl
} else {
null
@@ -119,7 +120,7 @@ class RichTextParser() {
val isRTL = isArabic(paragraph)
val wordList = paragraph.split(' ')
val wordList = paragraph.trimEnd().split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
@@ -179,11 +180,11 @@ class RichTextParser() {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (schemelessMatcher.find()) {
} else if (word.contains(".") && schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(word)) {
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)

View File

@@ -1,12 +1,11 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.events.Event
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -19,7 +18,7 @@ data class CashuToken(
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
val proofs: JsonNode
)
class CashuProcessor {
@@ -28,14 +27,14 @@ class CashuProcessor {
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
val cashu = jacksonObjectMapper().readTree(String(Base64.getDecoder().decode(base64token)))
val token = cashu.get("token").get(0)
val proofs = token.get("proofs")
val mint = token.get("mint").asText()
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
totalAmount += proof.get("amount").asLong()
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
@@ -46,7 +45,7 @@ class CashuProcessor {
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {
@@ -69,9 +68,11 @@ class CashuProcessor {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val factory = Event.mapper.nodeFactory
val jsonObject = factory.objectNode()
jsonObject.put("proofs", token.proofs)
jsonObject.put("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
@@ -80,16 +81,17 @@ class CashuProcessor {
.post(requestBody)
.build()
val response = client.newCall(request).execute()
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
client.newCall(request).execute().use {
val body = it.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
}
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)

View File

@@ -1,9 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.quartz.events.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}

View File

@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import java.security.MessageDigest
import kotlin.math.roundToInt
class FileHeader(
@@ -45,9 +45,7 @@ class FileHeader(
onError: () -> Unit
) {
try {
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(data).toHexKey()
val hash = CryptoUtils.sha256(data).toHexKey()
val size = data.size
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {

View File

@@ -1,23 +0,0 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}

View File

@@ -0,0 +1,79 @@
package com.vitorpamplona.amethyst.service
import android.annotation.SuppressLint
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.HandlerThread
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.flow.MutableStateFlow
class LocationUtil(context: Context) {
companion object {
const val MIN_TIME: Long = 1000L
const val MIN_DISTANCE: Float = 0.0f
}
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private var locationListener: LocationListener? = null
val locationStateFlow = MutableStateFlow<Location>(Location(LocationManager.NETWORK_PROVIDER))
val providerState = mutableStateOf(false)
val isStart: MutableState<Boolean> = mutableStateOf(false)
private val locHandlerThread = HandlerThread("LocationUtil Thread")
init {
locHandlerThread.start()
}
@SuppressLint("MissingPermission")
fun start(minTimeMs: Long = MIN_TIME, minDistanceM: Float = MIN_DISTANCE) {
locationListener().let {
locationListener = it
locationManager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, minTimeMs, minDistanceM, it, locHandlerThread.looper)
}
providerState.value = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
isStart.value = true
}
fun stop() {
locationListener?.let {
locationManager.removeUpdates(it)
}
isStart.value = false
}
private fun locationListener() = object : LocationListener {
override fun onLocationChanged(location: Location) {
locationStateFlow.value = location
}
override fun onProviderEnabled(provider: String) {
providerState.value = true
}
override fun onProviderDisabled(provider: String) {
providerState.value = false
}
}
}
class ReverseGeoLocationUtil {
suspend fun execute(
location: Location,
context: Context
): String? {
return try {
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull()?.let { address ->
listOfNotNull(address.locality ?: address.subAdminArea, address.countryCode).joinToString(", ")
}
} catch (e: Exception) {
e.printStackTrace()
return null
}
}
}

View File

@@ -2,10 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
@@ -26,55 +23,46 @@ class Nip05Verifier() {
return null
}
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchNip05Json(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
return@withContext
}
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()

View File

@@ -1,11 +1,9 @@
package com.vitorpamplona.amethyst.ui.note
import android.net.Uri
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
// Rename to the corect nip number when ready.
object Nip47 {

View File

@@ -1,24 +1,25 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.events.BadgeAwardEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrAccountDataSource : NostrDataSource("AccountData") {
lateinit var account: Account
@@ -47,6 +48,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountRelayListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(AdvertisedRelayListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
@@ -111,16 +123,52 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
)
fun createGiftWrapsToMeFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(GiftWrapEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
}
override fun consume(event: Event, relay: Relay) {
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGift(privateKey)?.let {
this.consume(it, relay)
}
}
}
if (event is SealedGossipEvent) {
val privateKey = account.keyPair.privKey
if (privateKey != null) {
event.cachedGossip(privateKey)?.let {
LocalCache.justConsume(it, relay)
}
}
// Don't store sealed gossips to avoid rebroadcasting by mistake.
} else {
LocalCache.justConsume(event, relay)
}
}
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createAccountRelayListFilter(),
createNotificationFilter(),
createGiftWrapsToMeFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter(),

View File

@@ -4,11 +4,11 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
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.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null

View File

@@ -1,32 +1,36 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
private var withRoom: ChatroomKey? = null
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = user
private val latestEOSEs = EOSEAccount()
fun loadMessagesBetween(accountIn: Account, withRoom: ChatroomKey) {
this.account = accountIn
this.withRoom = withRoom
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(myPeer.pubkeyHex),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
authors = myPeer.users.map { it },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -35,7 +39,7 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
val myPeer = withRoom
return if (myPeer != null) {
TypedFilter(
@@ -43,7 +47,8 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
tags = mapOf("p" to myPeer.users.map { it }),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(withRoom.hashCode().toString())?.relayList
)
)
} else {
@@ -51,9 +56,14 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
}
}
val inandoutChannel = requestNewChannel()
val inandoutChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.typedFilters = listOfNotNull(
createMessagesToMeFilter(),
createMessagesFromMeFilter()
).ifEmpty { null }
}
}

View File

@@ -1,15 +1,15 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account

View File

@@ -1,11 +1,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null

View File

@@ -2,21 +2,23 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.Error
abstract class NostrDataSource(val debugName: String) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
@@ -39,7 +41,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.verifyAndConsume(event, relay)
consume(event, relay)
}
}
@@ -77,6 +79,8 @@ abstract class NostrDataSource(val debugName: String) {
fun destroy() {
stop()
Client.unsubscribe(clientListener)
scope.cancel()
bundler.cancel()
}
open fun start() {
@@ -117,8 +121,7 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
scope.launch(Dispatchers.IO) {
resetFiltersSuspend()
}
}
@@ -164,6 +167,10 @@ abstract class NostrDataSource(val debugName: String) {
}
}
open fun consume(event: Event, relay: Relay) {
LocalCache.verifyAndConsume(event, relay)
}
abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit
}

View File

@@ -1,17 +1,17 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
@@ -92,6 +92,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createLiveStreamGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
@@ -112,6 +132,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createPublicChatsGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
@@ -132,6 +172,26 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
)
}
fun createCommunitiesGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
}
@@ -143,7 +203,10 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter()
createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter(),
createLiveStreamGeohashesFilter()
).ifEmpty { null }
}
}

View File

@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrGeohashDataSource : NostrDataSource("SingleGeoHashFeed") {
private var geohashToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = geohashToWatch ?: return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
tags = mapOf(
"g" to listOf(
hashToLoad
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)
}
val loadGeohashChannel = requestNewChannel()
override fun updateChannelFilters() {
loadGeohashChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
geohashToWatch = tag
invalidateFilters()
}
}

View File

@@ -1,13 +1,17 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
@@ -26,7 +30,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, LiveActivitiesChatMessageEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, AudioHeaderEvent.kind),
limit = 200
)
)

View File

@@ -2,22 +2,23 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -56,10 +57,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
it.substring(0, 8)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
@@ -73,6 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
AudioHeaderEvent.kind,
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
@@ -92,7 +94,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -104,6 +106,26 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioHeaderEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowCommunitiesFilter(): TypedFilter? {
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
@@ -117,6 +139,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
LongTextNoteEvent.kind,
ClassifiedsEvent.kind,
HighlightEvent.kind,
AudioHeaderEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
CommunityPostApprovalEvent.kind
@@ -135,6 +158,11 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowCommunitiesFilter(), createFollowTagsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(
createFollowAccountsFilter(),
createFollowCommunitiesFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter()
).ifEmpty { null }
}
}

View File

@@ -1,12 +1,12 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.RelayAuthEvent
class NostrLnZapPaymentResponseDataSource(
private val fromServiceHex: String,

View File

@@ -1,15 +1,15 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
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 fr.acinq.secp256k1.Hex
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.*
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
@@ -54,6 +54,29 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
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
)

View File

@@ -3,12 +3,12 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()

View File

@@ -2,17 +2,17 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createTagToAddressFilter(): List<TypedFilter>? {
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
if (addressesToWatch.isEmpty()) {
@@ -25,12 +25,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
TextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
PollNoteEvent.kind,
CommunityPostApprovalEvent.kind,
LiveActivitiesChatMessageEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime,
@@ -55,7 +58,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex)
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
} else {
@@ -64,7 +68,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
authors = listOf(aTag.pubKeyHex),
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -85,17 +90,12 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
PollNoteEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime,
@@ -154,7 +154,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createTagToAddressFilter()
val addressReactions = createReactionsToWatchInAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(missing, addresses, reactions, addressReactions).flatten().ifEmpty { null }
}

View File

@@ -1,12 +1,12 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.MetadataEvent
import com.vitorpamplona.quartz.events.ReportEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()

View File

@@ -1,10 +1,10 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.*
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
@@ -34,6 +34,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
RepostEvent.kind,
LongTextNoteEvent.kind,
AudioTrackEvent.kind,
AudioHeaderEvent.kind,
PinListEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind

View File

@@ -1,12 +1,12 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
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.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
@@ -51,11 +51,35 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
)
}
fun createFollowGeohashesFilter(): TypedFilter? {
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"g" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
)
)
}
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
}
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
videoFeedChannel.typedFilters = listOfNotNull(
createContextualFilter(),
createFollowTagsFilter(),
createFollowGeohashesFilter()
).ifEmpty { null }
}
}

View File

@@ -28,7 +28,9 @@ object OnlineChecker {
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
val result = HttpClient.getHttpClient().newCall(request).execute().use {
it.isSuccessful
}
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {

View File

@@ -7,16 +7,9 @@ object ConnectivityStatus {
private val onMobileData = mutableStateOf(false)
val isOnMobileData: MutableState<Boolean> = onMobileData
private val onWifi = mutableStateOf(false)
val isOnWifi: MutableState<Boolean> = onWifi
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
if (onMobileData.value != isOnMobileData) {
onMobileData.value = isOnMobileData
}
if (onWifi.value != isOnWifi) {
onWifi.value = isOnWifi
}
}
}

View File

@@ -4,12 +4,11 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.quartz.encoders.Bech32
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.toLnUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
@@ -38,106 +37,88 @@ class LightningAddressResolver() {
return null
}
fun fetchLightningAddressJson(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
private suspend fun fetchLightningAddressJson(lnaddress: String, onSuccess: suspend (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
return@withContext
}
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
client.newCall(request).execute().use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
e.printStackTrace()
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
suspend fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) = withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(
lnaddress,
onSuccess = {
onSuccess(Bech32.encodeBytes("lnurl", it.toByteArray(), Bech32.Encoding.Bech32))
onSuccess(it.toByteArray().toLnUrl())
},
onError = onError
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
suspend fun lnAddressInvoice(
lnaddress: String,
milliSats: Long,
message: String,
nostrRequest: String? = null,
onSuccess: (String) -> Unit,
onError: (String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(

View File

@@ -1,356 +0,0 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
import nostr.postr.toHex
import java.lang.reflect.Type
import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@Immutable
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: HexKey
) : EventInterface {
override fun id(): HexKey = id
override fun pubKey(): HexKey = pubKey
override fun createdAt(): Long = createdAt
override fun kind(): Int = kind
override fun tags(): List<List<String>> = tags
override fun content(): String = content
override fun sig(): HexKey = sig
override fun toJson(): String = gson.toJson(this)
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
override fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
override fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
override fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
}
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedAddressableNotes(idHexes: Set<String>) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
override fun isTaggedAddressableKind(kind: Int): Boolean {
val kindStr = kind.toString()
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
}
override fun getTagOfAddressableKind(kind: Int): ATag? {
val kindStr = kind.toString()
val aTag = tags
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
?.getOrNull(1)
?: return null
return ATag.parse(aTag, null)
}
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
if (id[i] == '0') {
rank += 4
} else if (id[i] in '4'..'7') {
rank += 1
break
} else if (id[i] in '2'..'3') {
rank += 2
break
} else if (id[i] == '1') {
rank += 3
break
} else {
break
}
}
return rank
}
override fun getReward(): BigDecimal? {
return try {
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) {
null
}
}
open fun toNIP19(): String {
return if (this is AddressableEvent) {
ATag(kind, pubKey, dTag(), null).toNAddr()
} else {
Nip19.createNEvent(id, pubKey, kind, null)
}
}
fun toNostrUri(): String {
return "nostr:${toNIP19()}"
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
override fun checkSignature() {
if (!id.contentEquals(generateId())) {
throw Exception(
"""|Unexpected ID.
| Event: ${toJson()}
| Actual ID: $id
| Generated: ${generateId()}
""".trimIndent()
)
}
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
throw Exception("""Bad signature!""")
}
}
override fun hasValidSignature(): Boolean {
return try {
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
} catch (e: Exception) {
Log.e("Event", "Fail checking if event $id has a valid signature", e)
false
}
}
private fun generateId(): String {
val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Event {
val jsonObject = json.asJsonObject
return Event(
id = jsonObject.get("id").asString,
pubKey = jsonObject.get("pubkey").asString,
createdAt = jsonObject.get("created_at").asLong,
kind = jsonObject.get("kind").asInt,
tags = jsonObject.get("tags").asJsonArray.map {
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
)
}
}
private class EventSerializer : JsonSerializer<Event> {
override fun serialize(
src: Event,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("id", src.id)
addProperty("pubkey", src.pubKey)
addProperty("created_at", src.createdAt)
addProperty("kind", src.kind)
add(
"tags",
JsonArray().also { jsonTags ->
src.tags.forEach { tag ->
jsonTags.add(
JsonArray().also { jsonTagElement ->
tag.forEach { tagElement ->
jsonTagElement.add(tagElement)
}
}
)
}
}
)
addProperty("content", src.content)
addProperty("sig", src.sig)
}
}
}
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ByteArray = Hex.decode(json.asString)
}
private class ByteArraySerializer : JsonSerializer<ByteArray> {
override fun serialize(
src: ByteArray,
typeOfSrc: Type?,
context: JsonSerializationContext?
) = JsonPrimitive(src.toHex())
}
companion object {
private val secp256k1 = Secp256k1.get()
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
NNSEvent.kind -> NNSEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> this
}
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
val rawEvent = listOf(
0,
pubKey,
createdAt,
kind,
tags,
content
)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey).toHexKey()
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
}
}
}
@Immutable
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
}

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.service.model.zaps
package com.vitorpamplona.quartz.events.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
import com.vitorpamplona.quartz.events.LnZapEventInterface
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {

View File

@@ -1,172 +0,0 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, EVENT, RELAY, ADDRESS
}
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
@Immutable
data class Return(
val type: Type,
val hex: String,
val relay: String? = null,
val author: String? = null,
val kind: Int? = null,
val additionalChars: String = ""
)
fun uriToRoute(uri: String?): Return? {
if (uri == null) return null
try {
val matcher = nip19regex.matcher(uri)
if (!matcher.find()) {
return null
}
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) // additional chars
return parseComponents(uriScheme, type, key, additionalChars)
} catch (e: Throwable) {
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
}
fun parseComponents(
uriScheme: String?,
type: String,
key: String?,
additionalChars: String?
): Return? {
return try {
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
parsed?.copy(additionalChars = additionalChars ?: "")
} catch (e: Throwable) {
Log.w("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e)
null
}
}
private fun npub(bytes: ByteArray): Return {
return Return(Type.USER, bytes.toHexKey())
}
private fun note(bytes: ByteArray): Return {
return Return(Type.NOTE, bytes.toHexKey())
}
private fun nprofile(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
}
private fun nevent(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.EVENT, hex, relay, author, kind)
}
private fun nrelay(bytes: ByteArray): Return? {
val relayUrl = Tlv.parse(bytes)
.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
return Return(Type.RELAY, relayUrl)
}
private fun naddr(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val d = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
}
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray()
val author = author?.hexToByteArray()
val idHex = idHex.hexToByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
if (relay != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
}
if (author != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author
}
if (kind != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
}
return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32)
}
}

View File

@@ -1,36 +0,0 @@
package com.vitorpamplona.amethyst.service.nip19
import java.nio.ByteBuffer
import java.nio.ByteOrder
object Tlv {
enum class Type(val id: Byte) {
SPECIAL(0),
RELAY(1),
AUTHOR(2),
KIND(3);
}
fun toInt32(bytes: ByteArray): Int {
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
}
fun parse(data: ByteArray): Map<Byte, List<ByteArray>> {
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
val t = rest[0]
val l = rest[1]
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
if (v.size < l) continue
if (!result.containsKey(t)) {
result[t] = mutableListOf()
}
result[t]?.add(v)
}
return result
}
}

View File

@@ -5,30 +5,93 @@ import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import kotlinx.collections.immutable.persistentSetOf
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
if (LocalCache.notes[event.id] == null) {
// adds to database
LocalCache.verifyAndConsume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null)
val manager = notificationManager()
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
}
}
}
}
}
fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null
return when (event) {
is GiftWrapEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGift(key)?.let {
unwrapAndConsume(it, account)
}
}
is SealedGossipEvent -> {
val key = account.keyPair.privKey ?: return null
event.cachedGossip(key)?.let {
// this is not verifiable
LocalCache.justConsume(it, null)
it
}
}
else -> {
LocalCache.justConsume(event, null)
event
}
}
}
private fun unwrapAndNotify(giftWrap: GiftWrapEvent) {
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
if (chatEvent is ChatMessageEvent && acc.keyPair.privKey != null) {
val chatNote = LocalCache.notes[chatEvent.id] ?: return
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet()
val isKnownRoom = (
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(chatRoom)
) && !acc.isAllHidden(chatRoom.users)
if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
@@ -43,19 +106,21 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
(
it.pubkeyHex in followingKeySet || acc.userProfile()
.hasSentMessagesTo(it)
) && !acc.isHidden(it)
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
if (note.author in messagingWith) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
@@ -103,7 +168,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
}
}
private fun notificationManager(): NotificationManager {
fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -18,8 +18,10 @@ import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
fun NotificationManager.getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
dmChannel = NotificationChannel(
@@ -39,7 +41,7 @@ object NotificationUtils {
return dmChannel!!
}
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
fun NotificationManager.getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel = NotificationChannel(
@@ -70,7 +72,7 @@ object NotificationUtils {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, ZAP_GROUP_KEY, applicationContext)
}
fun NotificationManager.sendDMNotification(
@@ -84,7 +86,7 @@ object NotificationUtils {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, DM_GROUP_KEY, applicationContext)
}
fun NotificationManager.sendNotification(
@@ -94,6 +96,7 @@ object NotificationUtils {
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
) {
if (pictureUrl != null) {
@@ -110,6 +113,7 @@ object NotificationUtils {
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
)
} else {
@@ -120,6 +124,7 @@ object NotificationUtils {
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext
)
}
@@ -132,6 +137,7 @@ object NotificationUtils {
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context
) {
val notId = id.hashCode()
@@ -164,7 +170,8 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
@@ -178,7 +185,8 @@ object NotificationUtils {
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
// .setGroup(messageTitle)
// .setGroup(notificationGroupKey) //-> Might need a Group summary as well before we activate this
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)

View File

@@ -5,11 +5,9 @@ import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import com.vitorpamplona.quartz.events.RelayAuthEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -56,18 +54,17 @@ class RegisterAccounts(
val client = HttpClient.getHttpClient()
client.newCall(request).execute()
val isSucess = client.newCall(request).execute().use {
it.isSuccessful
}
} catch (e: java.lang.Exception) {
Log.e("FirebaseMsgService", "Unable to register with push server", e)
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}

View File

@@ -1,25 +1,21 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) = withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
callback?.onFailed(t)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
@@ -43,7 +39,6 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
fun cleanUp() {
scope.cancel()
callback = null
}
}

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.previews
interface IUrlPreviewCallback {
fun onComplete(urlInfo: UrlInfoItem)
fun onFailed(throwable: Throwable)
suspend fun onComplete(urlInfo: UrlInfoItem)
suspend fun onFailed(throwable: Throwable)
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
@@ -61,10 +62,12 @@ private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
return@withContext Jsoup.connect(url)
.proxy(HttpClient.getProxy())
.timeout(timeOut)
.get()
val request: Request = Request.Builder().url(url).get().build()
val html = HttpClient.getHttpClient().newCall(request).execute().use {
it.body.string()
}
Jsoup.parse(html)
}
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =

View File

@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -17,16 +17,6 @@ import java.util.UUID
* Events are stored with their respective persona.
*/
object Client : RelayPool.Listener {
/**
* Lenient mode:
*
* true: For maximum compatibility. If you want to play ball with sloppy counterparts, use
* this.
* false: For developers who want to make protocol compliant counterparts. If your software
* produces events that fail to deserialize in strict mode, you should probably fix
* something.
**/
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
@@ -112,7 +102,7 @@ object Client : RelayPool.Listener {
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
relay.requestAndWatch {
relay.connectAndRun {
allSubscriptions().forEach {
relay.sendFilter(requestId = it)
}
@@ -178,6 +168,7 @@ object Client : RelayPool.Listener {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onAuth(relay: Relay, challenge: String) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.vitorpamplona.quartz.events.Event
class JsonFilter(
val ids: List<String>? = null,
@@ -15,51 +12,64 @@ class JsonFilter(
val limit: Int? = null,
val search: String? = null
) {
fun toJson(forRelay: String? = null): String {
val jsonObject = JsonObject()
ids?.run {
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
}
authors?.run {
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
}
kinds?.run {
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
}
tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
val factory = Event.mapper.nodeFactory
val filter = factory.objectNode().apply {
ids?.run {
put(
"ids",
factory.arrayNode(ids.size).apply {
ids.forEach { add(it) }
}
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
jsonObject.addProperty("since", relaySince.time)
authors?.run {
put(
"authors",
factory.arrayNode(authors.size).apply {
authors.forEach { add(it) }
}
} else {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
)
}
kinds?.run {
put(
"kinds",
factory.arrayNode(kinds.size).apply {
kinds.forEach { add(it) }
}
jsonObject.add("since", jsonObjectSince)
)
}
tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply {
kv.value.forEach { add(it) }
}
)
}
}
since?.run {
if (!isEmpty()) {
if (forRelay != null) {
val relaySince = get(forRelay)
if (relaySince != null) {
put("since", relaySince.time)
}
} else {
val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs ->
put(sincePairs.key, "${sincePairs.value}")
}
put("since", jsonObjectSince)
}
}
}
until?.run { put("until", until) }
limit?.run { put("limit", limit) }
search?.run { put("search", search) }
}
until?.run {
jsonObject.addProperty("until", until)
}
limit?.run {
jsonObject.addProperty("limit", limit)
}
search?.run {
jsonObject.addProperty("search", search)
}
return gson.toJson(jsonObject)
}
companion object {
val gson: Gson = GsonBuilder().create()
return Event.mapper.writeValueAsString(filter)
}
}

View File

@@ -1,13 +1,13 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.RelayAuthEvent
import com.vitorpamplona.quartz.events.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.TimeUtils
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -15,6 +15,7 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.concurrent.atomic.AtomicBoolean
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
@@ -29,14 +30,16 @@ class Relay(
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
companion object {
// waits 3 minutes to reconnect once things fail
const val RECONNECTING_IN_SECONDS = 60 * 3
}
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.readTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.connectTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.writeTimeout(Duration.ofSeconds(if (proxy != null) 20L else 10L))
.followRedirects(true)
.followSslRedirects(true)
.build()
@@ -50,9 +53,9 @@ class Relay(
var spamCounter = 0
var errorCounter = 0
var ping: Long? = null
var pingInMs: Long? = null
var closingTime = 0L
var closingTimeInSeconds = 0L
var afterEOSE = false
@@ -68,10 +71,8 @@ class Relay(
return socket != null
}
@Synchronized
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
fun connect() {
connectAndRun {
checkNotInMainThread()
// Sends everything.
@@ -81,9 +82,16 @@ class Relay(
}
}
@Synchronized
fun requestAndWatch(onConnected: (Relay) -> Unit) {
private var connectingBlock = AtomicBoolean()
fun connectAndRun(onConnected: (Relay) -> Unit) {
// If there is a connection, don't wait.
if (connectingBlock.getAndSet(true)) {
return
}
checkNotInMainThread()
if (socket != null) return
try {
@@ -91,132 +99,143 @@ class Relay(
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url.trim())
.build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
socket = httpClient.newWebSocket(request, RelayListener(onConnected))
} catch (e: Exception) {
errorCounter++
markConnectionAsClosed()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
} finally {
connectingBlock.set(false)
}
}
afterEOSE = false
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
inner class RelayListener(val onConnected: (Relay) -> Unit) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
markConnectionAsReady(response.receivedResponseAtMillis - response.sentRequestAtMillis)
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
onConnected(this@Relay)
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
processNewRelayMessage(text)
} catch (t: Throwable) {
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
}
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
null
)
}
}
try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
val channel = msg[1].asString
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
when (type) {
"EVENT" -> {
val event = Event.fromJson(msg[2], Client.lenient)
markConnectionAsClosed()
// Log.w("Relay", "Relay onEVENT $url, $channel")
listeners.forEach {
it.onEvent(this@Relay, channel, event)
if (afterEOSE) {
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
}
}
"EOSE" -> listeners.forEach {
afterEOSE = true
// Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msg[1].asString)
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
this@Relay,
channel,
Error("Unknown type $type on channel $channel. Msg was $text")
)
}
}
} catch (t: Throwable) {
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
}
}
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
null
)
}
}
errorCounter++
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
markConnectionAsClosed()
socket = null
isReady = false
afterEOSE = false
closingTime = TimeUtils.now()
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
}
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
fun markConnectionAsReady(pingInMs: Long) {
this.afterEOSE = false
this.isReady = true
this.pingInMs = pingInMs
}
errorCounter++
fun markConnectionAsClosed() {
this.socket = null
this.isReady = false
this.afterEOSE = false
this.closingTimeInSeconds = TimeUtils.now()
}
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
isReady = false
afterEOSE = false
closingTime = TimeUtils.now()
fun processNewRelayMessage(newMessage: String) {
val msgArray = Event.mapper.readTree(newMessage)
val type = msgArray.get(0).asText()
val channel = msgArray.get(1).asText()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: $response. Exception: ${t.message}", t))
when (type) {
"EVENT" -> {
val event = Event.fromJson(msgArray.get(2))
// Log.w("Relay", "Relay onEVENT $url, $channel")
listeners.forEach {
it.onEvent(this@Relay, channel, event)
if (afterEOSE) {
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
}
}
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
errorCounter++
isReady = false
afterEOSE = false
closingTime = TimeUtils.now()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
"EOSE" -> listeners.forEach {
afterEOSE = true
// Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asText()}, ${msgArray[2].asBoolean()}, ${msgArray[3].asText()}")
it.onSendResponse(this@Relay, msgArray[1].asText(), msgArray[2].asBoolean(), msgArray[3].asText())
}
"AUTH" -> listeners.forEach {
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText())
}
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
this@Relay,
channel,
Error("Unknown type $type on channel $channel. Msg was $newMessage")
)
}
}
}
fun disconnect() {
// httpClient.dispatcher.executorService.shutdown()
closingTime = TimeUtils.now()
closingTimeInSeconds = TimeUtils.now()
socket?.close(1000, "Normal close")
socket = null
isReady = false
@@ -241,9 +260,9 @@ class Relay(
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTime + 60) {
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// sends all filters after connection is successful.
requestAndWatch()
connect()
}
}
}
@@ -254,9 +273,9 @@ class Relay(
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > closingTime + 60) {
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
// println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
connect()
}
}
}
@@ -324,7 +343,3 @@ class Relay(
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}
fun String.bytesUsedInMemory(): Int {
return (8 * ((((this.length) * 2) + 45) / 8))
}

View File

@@ -2,20 +2,13 @@ package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool : Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -24,7 +17,7 @@ object RelayPool : Relay.Listener {
}
fun connectedRelays(): Int {
return relays.filter { it.isConnected() }.size
return relays.count { it.isConnected() }
}
fun getRelay(url: String): Relay? {
@@ -51,7 +44,7 @@ object RelayPool : Relay.Listener {
fun requestAndWatch() {
checkNotInMainThread()
relays.forEach { it.requestAndWatch() }
relays.forEach { it.connect() }
}
fun sendFilter(subscriptionId: String) {
@@ -136,9 +129,7 @@ object RelayPool : Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
scope.launch {
live.refresh()
}
live.refresh()
}
}

View File

@@ -1,8 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.fasterxml.jackson.databind.JsonNode
import com.vitorpamplona.quartz.events.Event
import java.util.UUID
data class Subscription(
@@ -16,15 +15,24 @@ data class Subscription(
}
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.addProperty("id", id)
typedFilters?.run {
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("id", id)
typedFilters?.also { filters ->
put(
"typedFilters",
factory.arrayNode(filters.size).apply {
filters.forEach { filter ->
add(filter.toJsonObject())
}
}
)
}
}
return jsonObject
}
}

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.vitorpamplona.quartz.events.Event
class TypedFilter(
val types: Set<FeedType>,
@@ -10,54 +10,74 @@ class TypedFilter(
) {
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
return Event.mapper.writeValueAsString(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("types", typesToJson(types))
jsonObject.add("filter", filterToJson(filter))
return jsonObject
fun toJsonObject(): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
put("types", typesToJson(types))
put("filter", filterToJson(filter))
}
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
fun typesToJson(types: Set<FeedType>): ArrayNode {
val factory = Event.mapper.nodeFactory
return factory.arrayNode(types.size).apply {
types.forEach { add(it.name.lowercase()) }
}
}
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
fun filterToJson(filter: JsonFilter): JsonNode {
val factory = Event.mapper.nodeFactory
return factory.objectNode().apply {
filter.ids?.run {
put(
"ids",
factory.arrayNode(filter.ids.size).apply {
filter.ids.forEach { add(it) }
}
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
filter.authors?.run {
put(
"authors",
factory.arrayNode(filter.authors.size).apply {
filter.authors.forEach { add(it) }
}
)
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run {
jsonObject.addProperty("until", filter.until)
filter.kinds?.run {
put(
"kinds",
factory.arrayNode(filter.kinds.size).apply {
filter.kinds.forEach { add(it) }
}
)
}
filter.tags?.run {
entries.forEach { kv ->
put(
"#${kv.key}",
factory.arrayNode(kv.value.size).apply {
kv.value.forEach { add(it) }
}
)
}
}
/*
Does not include since in the json comparison
filter.since?.run {
val jsonObjectSince = JsonObject()
entries.forEach { sincePairs ->
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
}
jsonObject.add("since", jsonObjectSince)
}*/
filter.until?.run { put("until", filter.until) }
filter.limit?.run { put("limit", filter.limit) }
filter.search?.run { put("search", filter.search) }
}
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
filter.search?.run {
jsonObject.addProperty("search", filter.search)
}
return jsonObject
}
}

View File

@@ -14,43 +14,32 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
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.navigation.getRouteWithArguments
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -59,15 +48,10 @@ import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
lateinit var navController: NavHostController
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
@@ -76,7 +60,6 @@ class MainActivity : AppCompatActivity() {
}
setContent {
navController = rememberNavController()
val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
@@ -87,17 +70,9 @@ class MainActivity : AppCompatActivity() {
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, themeViewModel, navController)
AccountScreen(accountStateViewModel, themeViewModel)
}
}
var actionableNextPage by remember { mutableStateOf(startingPage) }
actionableNextPage?.let {
LaunchedEffect(it) {
navController.navigate(it)
}
actionableNextPage = null
}
}
val networkRequest = NetworkRequest.Builder()
@@ -108,8 +83,6 @@ class MainActivity : AppCompatActivity() {
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
connectivityManager.requestNetwork(networkRequest, networkCallback)
Client.lenient = true
}
@OptIn(DelicateCoroutinesApi::class)
@@ -124,28 +97,32 @@ class MainActivity : AppCompatActivity() {
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
GlobalScope.launch(Dispatchers.IO) {
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
}
override fun onPause() {
// if (BuildConfig.DEBUG) {
debugState(this)
// }
ServiceManager.pause()
if (BuildConfig.DEBUG) {
debugState(this)
}
super.onPause()
}
override fun onDestroy() {
super.onDestroy()
keepPlayingMutex?.stop()
keepPlayingMutex?.release()
keepPlayingMutex = null
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
* @param level the memory-related event that was raised.
*/
@OptIn(DelicateCoroutinesApi::class)
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
@@ -154,39 +131,7 @@ class MainActivity : AppCompatActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
startingPage?.let { route ->
val currentRoute = getRouteWithArguments(navController)
if (!isSameRoute(currentRoute, route)) {
navController.navigate(route) {
popUpTo(Route.Home.route)
launchSingleTop = true
}
}
}
}
private fun isSameRoute(currentRoute: String?, newRoute: String): Boolean {
if (currentRoute == null) return false
if (currentRoute == newRoute) {
return true
}
if (newRoute.startsWith("Event/") && currentRoute.contains("/")) {
if (newRoute.split("/")[1] == currentRoute.split("/")[1]) {
return true
}
}
return false
}
@OptIn(DelicateCoroutinesApi::class)
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
// network is available for use
override fun onAvailable(network: Network) {
@@ -207,14 +152,13 @@ class MainActivity : AppCompatActivity() {
super.onCapabilitiesChanged(network, networkCapabilities)
GlobalScope.launch(Dispatchers.IO) {
val hasMobileData =
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
ConnectivityStatus.updateConnectivityStatus(
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
hasMobileData,
hasWifi
)
}
}
@@ -254,7 +198,9 @@ fun uriToRoute(uri: String?): String? {
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
nip19.author?.let {
"RoomByAuthor/$it"
}
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {

View File

@@ -8,6 +8,7 @@ import androidx.core.net.toFile
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
@@ -140,6 +141,7 @@ object ImageUploader {
fun NIP98Header(url: String, method: String, body: String): String {
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
return "Nostr " + encodedNIP98Event
}
@@ -189,17 +191,20 @@ class ImgurServer : FileServer() {
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
val tree = jacksonObjectMapper().readTree(body)
val data = tree?.get("data")
val data0 = data?.get(0)
val url = data0?.get("url")
return url.toString().replace("\"", "")
}
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
return "file"
}
override fun clientID(info: String) = null
override fun clientID(info: String) = ImageUploader.NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info)
}
class NostrFilesDevServer : FileServer() {
@@ -224,7 +229,7 @@ class NostrCheckMeServer : FileServer() {
var id = tree?.get("id")?.asText()
var isCompleted = false
val client = OkHttpClient()
val client = HttpClient.getHttpClient()
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
val request = Request.Builder()
@@ -234,14 +239,14 @@ class NostrCheckMeServer : FileServer() {
.build()
while (!isCompleted) {
val response = client.newCall(request).execute()
val body = response.body?.string()
val tree = jacksonObjectMapper().readTree(body)
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
client.newCall(request).execute().use {
val tree = jacksonObjectMapper().readTree(it.body.string())
isCompleted = tree?.get("status")?.asText() == "completed"
try {
Thread.sleep(500)
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
}
return url

View File

@@ -67,6 +67,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatroomKey
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -300,6 +302,7 @@ private fun RenderSearchResults(
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
@@ -320,7 +323,12 @@ private fun RenderSearchResults(
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
scope.launch(Dispatchers.IO) {
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
accountViewModel.userProfile().createChatroom(withKey)
nav("Room/${withKey.hashCode()}")
}
searchBarViewModel.clear()
}
}

View File

@@ -7,14 +7,15 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -28,8 +29,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: PublicChatChannel? = null) {
@@ -44,7 +43,7 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp)
modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
@@ -57,14 +56,10 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan
onClose()
})
val scope = rememberCoroutineScope()
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.create()
onClose()
}
postViewModel.create()
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
)

View File

@@ -3,8 +3,11 @@ package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.PublicChatChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class NewChannelViewModel : ViewModel() {
private var account: Account? = null
@@ -25,24 +28,26 @@ class NewChannelViewModel : ViewModel() {
}
fun create() {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
viewModelScope.launch(Dispatchers.IO) {
account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
} else {
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
}
}
clear()
clear()
}
}
fun clear() {

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -56,7 +57,7 @@ import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val account = accountViewModel.account
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
@@ -240,6 +241,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
postViewModel.galleryUri?.let {
VideoView(
videoUri = it.toString(),
roundedCorner = false,
accountViewModel = accountViewModel
)
}

View File

@@ -1,13 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.toNpub
class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
class NewMessageTagger(
var message: String,
var mentions: List<User>? = null,
var replyTos: List<Note>? = null,
var channelHex: String? = null
) {
val directMentions = mutableSetOf<HexKey>()
@@ -83,4 +90,55 @@ class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var r
}.joinToString(" ")
}.joinToString("\n")
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
if (key.startsWith("nostr:", true)) {
key = key.substring("nostr:".length)
}
key = key.removePrefix("@")
if (key.length < 63) {
return null
}
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
// Converts to npub
val pubkey = Nip19.uriToRoute(KeyPair(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
val noteId = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars)
} else if (key.startsWith("nevent1", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
@@ -22,6 +23,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.LocationOff
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -31,7 +34,7 @@ import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -66,10 +69,16 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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.model.Note
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.CancelIcon
@@ -78,36 +87,45 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.PollIcon
import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
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.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.toImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewPostView(
onClose: () -> Unit,
baseReplyTo: Note? = null,
quote: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val account = remember(accountViewModel) { accountViewModel.account }
val postViewModel: NewPostViewModel = viewModel()
postViewModel.wantsDirectMessage = enableMessageInterface
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
var showRelaysDialog by remember {
@@ -123,8 +141,6 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
delay(100)
focusRequester.requestFocus()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
@@ -241,65 +257,58 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.removeFromReplyList(it)
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.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.colors.placeholderText
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
if (enableMessageInterface) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
SendDirectMessageTo(postViewModel = postViewModel)
}
}
MessageField(postViewModel)
if (postViewModel.wantsPoll) {
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
Button(
onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = "" },
border = BorderStroke(1.dp, MaterialTheme.colors.placeholderText),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.placeholderText
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
PollField(postViewModel)
}
}
if (postViewModel.wantsToMarkAsSensitive) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
ContentSensitivityExplainer(postViewModel)
}
}
if (postViewModel.wantsToAddGeoHash) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = Size5dp, horizontal = Size10dp)
) {
LocationAsHash(postViewModel)
}
}
if (postViewModel.wantsForwardZapTo) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)
) {
FowardZapTo(postViewModel)
}
}
val url = postViewModel.contentToAddUrl
if (url != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ImageVideoDescription(
url,
account.defaultFileServer,
@@ -324,26 +333,28 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val lud16 = user?.info?.lnAddress()
if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
user.pubkeyHex,
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
}
)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
Column(Modifier.fillMaxWidth()) {
InvoiceRequest(
lud16,
user.pubkeyHex,
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
}
)
}
}
}
if (lud16 != null && postViewModel.wantsZapraiser) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
ZapRaiserRequest(
stringResource(id = R.string.zapraiser),
postViewModel
@@ -353,7 +364,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
@@ -373,7 +384,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview, accountViewModel = accountViewModel)
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
@@ -456,6 +467,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
AddGeoHash(postViewModel) {
postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
@@ -466,6 +481,406 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
@Composable
private fun PollField(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
Button(
onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] =
""
},
border = BorderStroke(
1.dp,
MaterialTheme.colors.placeholderText
),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colors.placeholderText
)
) {
Image(
painterResource(id = android.R.drawable.ic_input_add),
contentDescription = "Add poll option button",
modifier = Modifier.size(18.dp)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun MessageField(
postViewModel: NewPostViewModel
) {
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.colors.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.colors.placeholderText
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun ContentSensitivityExplainer(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = Color.Red
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = Color.Yellow
)
}
Text(
text = stringResource(R.string.add_sensitive_content_label),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
Text(
text = stringResource(R.string.add_sensitive_content_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}
}
@Composable
fun SendDirectMessageTo(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.messages_new_message_to),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.toUsers,
onValueChange = {
postViewModel.updateToUsers(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_to_caption),
color = MaterialTheme.colors.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colors.primary
),
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Divider()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.messages_new_message_subject),
fontSize = Font14SP,
fontWeight = FontWeight.W500
)
MyTextField(
value = postViewModel.subject,
onValueChange = {
postViewModel.updateSubject(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colors.placeholderText
)
},
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colors.primary
),
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Divider()
}
}
@Composable
fun FowardZapTo(postViewModel: NewPostViewModel) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Box(
Modifier
.height(20.dp)
.width(25.dp)
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(20.dp)
.align(Alignment.CenterStart),
tint = BitcoinOrange
)
Icon(
imageVector = Icons.Outlined.ArrowForwardIos,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier
.size(13.dp)
.align(Alignment.CenterEnd),
tint = BitcoinOrange
)
}
Text(
text = stringResource(R.string.zap_forward_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
Text(
text = stringResource(R.string.zap_forward_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
label = { Text(text = stringResource(R.string.zap_forward_lnAddress)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.placeholderText
)
},
singleLine = true,
visualTransformation = UrlUserTagTransformation(
MaterialTheme.colors.primary
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
@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()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Box(
Modifier
.height(20.dp)
.width(20.dp)
) {
Icon(
imageVector = Icons.Default.LocationOn,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
}
Text(
text = stringResource(R.string.geohash_title),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
locationDescriptionFlow?.let { geoLocation ->
DisplayLocationObserver(geoLocation)
}
}
Divider()
Text(
text = stringResource(R.string.geohash_explainer),
color = MaterialTheme.colors.placeholderText,
modifier = Modifier.padding(vertical = 10.dp)
)
}
} else {
LaunchedEffect(locationPermissionState) {
locationPermissionState.launchPermissionRequest()
}
}
}
@Composable
fun DisplayLocationObserver(geoLocation: Flow<String>) {
val location by geoLocation.collectAsState(initial = 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") {
Text(
text = cityName,
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = Size5dp)
)
} else {
Spacer(modifier = StdHorzSpacer)
LoadingAnimation()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
@@ -582,6 +997,31 @@ private fun AddZapraiserButton(
}
}
@Composable
fun AddGeoHash(postViewModel: NewPostViewModel, onClick: () -> Unit) {
IconButton(
onClick = {
onClick()
}
) {
if (!postViewModel.wantsToAddGeoHash) {
Icon(
imageVector = Icons.Default.LocationOff,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.LocationOn,
null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun AddLnInvoiceButton(
isLnInvoiceActive: Boolean,
@@ -662,33 +1102,6 @@ private fun ForwardZapTo(
}
}
}
if (postViewModel.wantsForwardZapTo) {
OutlinedTextField(
value = postViewModel.forwardZapToEditting,
onValueChange = {
postViewModel.updateZapForwardTo(it)
},
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
.padding(0.dp),
placeholder = {
Text(
text = stringResource(R.string.zap_forward_lnAddress),
color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
@Composable
@@ -819,21 +1232,6 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
}
enum class ServersAvailable {
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
NOSTRCHECK_ME,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
NOSTRCHECK_ME_NIP_94,
NIP95
}
@Composable
fun ImageVideoDescription(
uri: Uri,
@@ -913,7 +1311,9 @@ fun ImageVideoDescription(
)
IconButton(
modifier = Modifier.size(30.dp).padding(end = 5.dp),
modifier = Modifier
.size(30.dp)
.padding(end = 5.dp),
onClick = onCancel
) {
CancelIcon()
@@ -964,7 +1364,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri.toString(), accountViewModel = accountViewModel)
VideoView(uri.toString(), roundedCorner = true, accountViewModel = accountViewModel)
}
}
@@ -1045,13 +1445,6 @@ fun ImageVideoDescription(
}
}
@Stable
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@Composable
fun SettingSwitchItem(
modifier: Modifier = Modifier,

View File

@@ -13,25 +13,39 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.LocationUtil
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
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.isValidURL
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
FORWARD_ZAPS,
TO_USERS
}
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var requiresNIP24: Boolean = false
var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
@@ -44,7 +58,12 @@ open class NewPostViewModel() : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: Boolean? = null
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
// DMs
var wantsDirectMessage by mutableStateOf(false)
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
@@ -77,11 +96,19 @@ open class NewPostViewModel() : ViewModel() {
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var locUtil: LocationUtil? = null
var location: Flow<String>? = null
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -121,6 +148,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = null
@@ -130,9 +158,13 @@ open class NewPostViewModel() : ViewModel() {
}
fun sendPost(relayList: List<Relay>? = null) {
val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text)
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
toUsersTagger.run()
val dmUsers = toUsersTagger.mentions
val zapReceiver = if (wantsForwardZapTo) {
if (forwardZapTo != null) {
forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
@@ -143,52 +175,102 @@ open class NewPostViewModel() : ViewModel() {
null
}
val geoLocation = locUtil?.locationStateFlow?.value
val geoHash = if (wantsToAddGeoHash && geoLocation != null) {
geoLocation.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
} else {
null
}
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount else null
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList
)
} else if (originalNote?.channelHex() != null) {
if (originalNote?.channelHex() != null) {
if (originalNote is AddressableEvent && originalNote?.address() != null) {
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
account?.sendLiveMessage(tagger.message, originalNote?.address()!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else {
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
}
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount)
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // 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.
val replyId = originalNote?.idHex
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive, localZapRaiserAmount, geoHash)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers = (originalNote?.event as ChatMessageEvent).recipientsPubKey().plus(originalNote?.author?.pubkeyHex).filterNotNull().toSet().toList()
account?.sendPost(
account?.sendNIP24PrivateMessage(
message = tagger.message,
replyTo = tagger.replyTos,
toUsers = receivers,
subject = subject.text.ifBlank { null },
replyingTo = originalNote!!,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList
geohash = geoHash
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
account?.sendNIP24PrivateMessage(
message = tagger.message,
toUsers = dmUsers.map { it.pubkeyHex },
subject = subject.text.ifBlank { null },
replyingTo = tagger.replyTos?.firstOrNull(),
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
} else {
account?.sendPrivateMessage(
message = tagger.message,
toUser = dmUsers.first().pubkeyHex,
replyingTo = originalNote,
mentions = tagger.mentions,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash
)
}
} else {
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.replyTos,
tagger.mentions,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash
)
} else {
// adds markers
val rootId =
(originalNote?.event as? TextNoteEvent)?.root() // 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.
val replyId = originalNote?.idHex
account?.sendPost(
message = tagger.message,
replyTo = tagger.replyTos,
mentions = tagger.mentions,
tags = null,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
replyingTo = replyId,
root = rootId,
directMentions = tagger.directMentions,
relayList = relayList,
geohash = geoHash
)
}
}
cancel()
@@ -248,11 +330,16 @@ open class NewPostViewModel() : ViewModel() {
open fun cancel() {
message = TextFieldValue("")
toUsers = TextFieldValue("")
subject = TextFieldValue("")
contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
wantsDirectMessage = false
wantsPoll = false
zapRecipients = mutableStateListOf<HexKey>()
pollOptions = newStateMapPollOptions()
@@ -267,6 +354,7 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -296,7 +384,7 @@ open class NewPostViewModel() : ViewModel() {
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
@@ -311,12 +399,37 @@ open class NewPostViewModel() : ViewModel() {
}
}
open fun updateToUsers(it: TextFieldValue) {
toUsers = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS
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 updateSubject(it: TextFieldValue) {
subject = it
}
open fun updateZapForwardTo(it: TextFieldValue) {
forwardZapToEditting = it
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS
if (lastWord.startsWith("@") && lastWord.length > 2) {
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
viewModelScope.launch(Dispatchers.IO) {
@@ -337,7 +450,7 @@ open class NewPostViewModel() : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
if (userSuggestionsMainMessage == true) {
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()}"
@@ -346,7 +459,7 @@ open class NewPostViewModel() : ViewModel() {
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else {
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) {
val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
@@ -356,6 +469,15 @@ open class NewPostViewModel() : ViewModel() {
forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
} else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) {
val lastWord = toUsers.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()}"
toUsers = TextFieldValue(
toUsers.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
}
userSuggestionAnchor = null
@@ -369,7 +491,11 @@ open class NewPostViewModel() : ViewModel() {
}
fun canPost(): Boolean {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) &&
contentToAddUrl == null
}
fun includePollHashtagInMessage(include: Boolean, hashtag: String) {
@@ -449,4 +575,58 @@ open class NewPostViewModel() : ViewModel() {
fun selectImage(uri: Uri) {
contentToAddUrl = uri
}
fun startLocation(context: Context) {
locUtil = LocationUtil(context)
locUtil?.let {
location = it.locationStateFlow.mapLatest {
it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString()
}
}
viewModelScope.launch(Dispatchers.IO) {
locUtil?.start()
}
}
fun stopLocation() {
viewModelScope.launch(Dispatchers.IO) {
locUtil?.stop()
}
location = null
locUtil = null
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch(Dispatchers.IO) {
locUtil?.stop()
}
location = null
locUtil = null
}
fun toggleNIP04And24() {
if (requiresNIP24) {
nip24 = true
} else {
nip24 = !nip24
}
}
}
enum class GeohashPrecision(val digits: Int) {
KM_5000_X_5000(1), // 5,000km × 5,000km
KM_1250_X_625(2), // 1,250km × 625km
KM_156_X_156(3), // 156km × 156km
KM_39_X_19(4), // 39.1km × 19.5km
KM_5_X_5(5), // 4.89km × 4.89km
M_1000_X_600(6), // 1.22km × 0.61km
M_153_X_153(7), // 153m × 153m
M_38_X_19(8), // 38.2m × 19.1m
M_5_X_5(9), // 4.77m × 4.77m
MM_1000_X_1000(10), // 1.19m × 0.596m
MM_149_X_149(11), // 149mm × 149mm
MM_37_X_18(12) // 37.2mm × 18.6mm
}

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
@@ -23,8 +22,9 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
@@ -58,11 +58,14 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@@ -85,37 +88,68 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
Scaffold(
topBar = {
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth().padding(end = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = StdHorzSpacer)
PostButton(
onPost = {
postViewModel.create()
Button(
onClick = {
postViewModel.deleteAll()
defaultRelays.forEach {
postViewModel.addRelay(it)
}
postViewModel.relays.value.forEach { item ->
loadRelayInfo(item.url, context, scope) {
postViewModel.togglePaidRelay(item, it.limitation?.payment_required ?: false)
}
}
}
) {
Text(stringResource(R.string.default_relays))
}
PostButton(
onPost = {
postViewModel.create()
onClose()
},
true
)
}
},
navigationIcon = {
Spacer(modifier = StdHorzSpacer)
CloseButton(onCancel = {
postViewModel.clear()
onClose()
},
true
)
}
Spacer(modifier = Modifier.height(10.dp))
})
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { pad ->
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.padding(
16.dp,
pad.calculateTopPadding(),
16.dp,
pad.calculateBottomPadding()
),
verticalArrangement = Arrangement.SpaceAround
) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
@@ -149,7 +183,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
}
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = StdVertSpacer)
EditableServerConfig(relayToAdd) {
postViewModel.addRelay(it)

View File

@@ -4,10 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.quartz.events.ContactListEvent
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -90,6 +90,12 @@ class NewRelayListViewModel : ViewModel() {
}
}
fun deleteAll() {
_relays.update { relays ->
relays.dropWhile { relays.isNotEmpty() }
}
}
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))

View File

@@ -10,11 +10,10 @@ import androidx.lifecycle.viewModelScope
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import id.zelory.compressor.Compressor.compress
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.TwitterIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch

View File

@@ -323,7 +323,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -334,7 +334,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -343,12 +343,12 @@ fun loadRelayInfo(
}
override fun onFailure(call: Call, e: IOException) {
Log.e("RelayInfoFail", "Resulting Message from Relay in not parseable $dirtyUrl", e)
Log.e("RelayInfoFail", "$dirtyUrl unavailable", e)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}
@@ -361,7 +361,7 @@ fun loadRelayInfo(
Toast
.makeText(
context,
context.getString(R.string.an_error_ocurred_trying_to_get_relay_information, dirtyUrl),
context.getString(R.string.an_error_occurred_trying_to_get_relay_information, dirtyUrl),
Toast.LENGTH_SHORT
).show()
}

View File

@@ -24,9 +24,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInformation
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,7 +39,6 @@ data class RelayList(
val isSelected: Boolean
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RelaySelectionDialog(
list: List<Relay>,
@@ -79,6 +80,10 @@ fun RelaySelectionDialog(
)
}
var selected by remember {
mutableStateOf(true)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -115,7 +120,7 @@ fun RelaySelectionDialog(
val selectedRelays = relays.filter { it.isSelected }
if (selectedRelays.isEmpty()) {
scope.launch {
Toast.makeText(context, "Select a relay to continue", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.select_a_relay_to_continue), Toast.LENGTH_SHORT).show()
}
return@PostButton
}
@@ -126,6 +131,17 @@ fun RelaySelectionDialog(
)
}
RelaySwitch(
text = stringResource(R.string.select_deselect_all),
checked = selected,
onClick = {
selected = !selected
relays = relays.mapIndexed { _, item ->
item.copy(isSelected = selected)
}
}
)
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
@@ -136,48 +152,55 @@ fun RelaySelectionDialog(
relays,
key = { _, item -> item.relay.url }
) { index, item ->
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
relays = relays.mapIndexed { j, item ->
if (index == j) {
item.copy(isSelected = !item.isSelected)
} else {
item
}
}
},
onLongClick = {
loadRelayInfo(item.relay.url, context, scope) {
relayInfo = it
}
}
)
) {
Text(
item.relay.url
.removePrefix("ws://")
.removePrefix("wss://")
.removeSuffix("/")
)
Switch(
checked = item.isSelected,
onCheckedChange = {
relays = relays.mapIndexed { j, item ->
if (index == j) {
item.copy(isSelected = !item.isSelected)
} else { item }
RelaySwitch(
text = item.relay.url
.removePrefix("ws://")
.removePrefix("wss://")
.removeSuffix("/"),
checked = item.isSelected,
onClick = {
relays = relays.mapIndexed { j, item ->
if (index == j) {
item.copy(isSelected = !item.isSelected)
} else {
item
}
}
)
}
},
onLongPress = {
loadRelayInfo(item.relay.url, context, scope) {
relayInfo = it
}
}
)
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RelaySwitch(text: String, checked: Boolean, onClick: () -> Unit, onLongPress: () -> Unit = { }) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.combinedClickable(
onClick = onClick,
onLongClick = onLongPress
)
) {
Text(
modifier = Modifier.weight(1f),
text = text
)
Switch(
checked = checked,
onCheckedChange = {
onClick()
}
)
}
}

View File

@@ -11,8 +11,8 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.encoders.toHexKey
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
@@ -43,7 +43,8 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val key =
decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val newWord = "@${user.toBestDisplayName()}"

View File

@@ -16,9 +16,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font12SP
@Composable
fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -26,7 +27,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
mutableStateOf(false)
}
var wantsToJoinChannelOrUser by remember {
var wantsToSendNewMessage by remember {
mutableStateOf(false)
}
@@ -38,23 +39,25 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel)
}
if (wantsToJoinChannelOrUser) {
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
if (wantsToSendNewMessage) {
NewPostView({ wantsToSendNewMessage = false }, enableMessageInterface = true, accountViewModel = accountViewModel, nav = nav)
// JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
}
Column() {
if (isOpen) {
OutlinedButton(
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
onClick = { wantsToSendNewMessage = true; isOpen = false },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(bottom = 3.dp)
) {
Text(
text = stringResource(R.string.channel_list_join_channel),
text = stringResource(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
fontSize = Font12SP
)
}
@@ -68,9 +71,10 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
contentPadding = PaddingValues(bottom = 3.dp)
) {
Text(
text = stringResource(R.string.channel_list_create_channel),
text = stringResource(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
fontSize = Font12SP
)
}
@@ -86,7 +90,7 @@ fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit)
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.new_channel),
contentDescription = stringResource(R.string.messages_create_public_chat),
modifier = Modifier.size(26.dp),
tint = Color.White
)

View File

@@ -48,7 +48,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit, navScrollToTop: (Route, Boolean) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
@@ -62,11 +62,9 @@ fun NewImageButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val postViewModel: NewMediaModel = viewModel()
postViewModel.onceUploaded {
scope.launch(Dispatchers.Default) {
// awaits an refresh on the list
delay(250)
delay(500)
withContext(Dispatchers.Main) {
val route = Route.Video.route.replace("{scrollToTop}", "true")
nav(route)
navScrollToTop(Route.Video, true)
}
}
}

View File

@@ -0,0 +1,160 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceIn
import androidx.compose.ui.unit.dp
import com.linc.audiowaveform.model.AmplitudeType
import com.linc.audiowaveform.model.WaveformAlignment
import kotlin.math.ceil
import kotlin.math.roundToInt
private val MinSpikeWidthDp: Dp = 1.dp
private val MaxSpikeWidthDp: Dp = 24.dp
private val MinSpikePaddingDp: Dp = 0.dp
private val MaxSpikePaddingDp: Dp = 12.dp
private val MinSpikeRadiusDp: Dp = 0.dp
private val MaxSpikeRadiusDp: Dp = 12.dp
private const val MinProgress: Float = 0F
private const val MaxProgress: Float = 1F
private const val MinSpikeHeight: Float = 1F
private const val DefaultGraphicsLayerAlpha: Float = 0.99F
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AudioWaveformReadOnly(
modifier: Modifier = Modifier,
style: DrawStyle = Fill,
waveformBrush: Brush = SolidColor(Color.White),
progressBrush: Brush = SolidColor(Color.Blue),
waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
amplitudeType: AmplitudeType = AmplitudeType.Avg,
onProgressChangeFinished: (() -> Unit)? = null,
spikeAnimationSpec: AnimationSpec<Float> = tween(500),
spikeWidth: Dp = 3.dp,
spikeRadius: Dp = 2.dp,
spikePadding: Dp = 2.dp,
progress: Float = 0F,
amplitudes: List<Int>,
onProgressChange: (Float) -> Unit
) {
val backgroundColor = MaterialTheme.colors.background
val _progress = remember(progress) { progress.coerceIn(MinProgress, MaxProgress) }
val _spikeWidth = remember(spikeWidth) { spikeWidth.coerceIn(MinSpikeWidthDp, MaxSpikeWidthDp) }
val _spikePadding = remember(spikePadding) { spikePadding.coerceIn(MinSpikePaddingDp, MaxSpikePaddingDp) }
val _spikeRadius = remember(spikeRadius) { spikeRadius.coerceIn(MinSpikeRadiusDp, MaxSpikeRadiusDp) }
val _spikeTotalWidth = remember(spikeWidth, spikePadding) { _spikeWidth + _spikePadding }
var canvasSize by remember { mutableStateOf(Size(0f, 0f)) }
var spikes by remember { mutableStateOf(0F) }
val spikesAmplitudes = remember(amplitudes, spikes, amplitudeType) {
amplitudes.toDrawableAmplitudes(
amplitudeType = amplitudeType,
spikes = spikes.toInt(),
minHeight = MinSpikeHeight,
maxHeight = canvasSize.height.coerceAtLeast(MinSpikeHeight)
)
}.map { animateFloatAsState(it, spikeAnimationSpec).value }
Canvas(
modifier = Modifier
.fillMaxWidth()
.requiredHeight(48.dp)
.graphicsLayer(alpha = DefaultGraphicsLayerAlpha)
.then(modifier)
) {
canvasSize = size
spikes = size.width / _spikeTotalWidth.toPx()
spikesAmplitudes.forEachIndexed { index, amplitude ->
drawRoundRect(
brush = waveformBrush,
topLeft = Offset(
x = index * _spikeTotalWidth.toPx(),
y = when (waveformAlignment) {
WaveformAlignment.Top -> 0F
WaveformAlignment.Bottom -> size.height - amplitude
WaveformAlignment.Center -> size.height / 2F - amplitude / 2F
}
),
size = Size(
width = _spikeWidth.toPx(),
height = amplitude
),
cornerRadius = CornerRadius(_spikeRadius.toPx(), _spikeRadius.toPx()),
style = style
)
drawRect(
brush = progressBrush,
size = Size(
width = _progress * size.width,
height = size.height
),
blendMode = BlendMode.SrcAtop
)
}
}
}
private fun List<Int>.toDrawableAmplitudes(
amplitudeType: AmplitudeType,
spikes: Int,
minHeight: Float,
maxHeight: Float
): List<Float> {
val amplitudes = map(Int::toFloat)
if (amplitudes.isEmpty() || spikes == 0) {
return List(spikes) { minHeight }
}
val transform = { data: List<Float> ->
when (amplitudeType) {
AmplitudeType.Avg -> data.average()
AmplitudeType.Max -> data.max()
AmplitudeType.Min -> data.min()
}.toFloat().coerceIn(minHeight, maxHeight)
}
return when {
spikes > amplitudes.count() -> amplitudes.fillToSize(spikes, transform)
else -> amplitudes.chunkToSize(spikes, transform)
}.normalize(minHeight, maxHeight)
}
internal fun <T> Iterable<T>.fillToSize(size: Int, transform: (List<T>) -> T): List<T> {
val capacity = ceil(size.safeDiv(count())).roundToInt()
return map { data -> List(capacity) { data } }.flatten().chunkToSize(size, transform)
}
internal fun <T> Iterable<T>.chunkToSize(size: Int, transform: (List<T>) -> T): List<T> {
val chunkSize = count() / size
val remainder = count() % size
val remainderIndex = ceil(count().safeDiv(remainder)).roundToInt()
val chunkIteration = filterIndexed { index, _ ->
remainderIndex == 0 || index % remainderIndex != 0
}.chunked(chunkSize, transform)
return when (size) {
chunkIteration.count() -> chunkIteration
else -> chunkIteration.chunkToSize(size, transform)
}
}
internal fun Iterable<Float>.normalize(min: Float, max: Float): List<Float> {
return map { (max - min) * ((it - min()) / (max() - min())) + min }
}
private fun Int.safeDiv(value: Int): Float {
return if (value == 0) return 0F else this / value.toFloat()
}

View File

@@ -5,8 +5,9 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -21,6 +22,8 @@ class BundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
@@ -32,7 +35,6 @@ class BundledUpdate(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
try {
onUpdate()
@@ -48,6 +50,10 @@ class BundledUpdate(
}
}
}
fun cancel() {
scope.cancel()
}
}
/**
@@ -58,6 +64,8 @@ class BundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
val scope = CoroutineScope(dispatcher + SupervisorJob())
private var onlyOneInBlock = AtomicBoolean()
private var queue = LinkedBlockingQueue<T>()
@@ -69,7 +77,6 @@ class BundledInsert<T>(
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch(Dispatchers.IO) {
try {
val mySet = mutableSetOf<T>()
@@ -88,4 +95,8 @@ class BundledInsert<T>(
}
}
}
fun cancel() {
scope.cancel()
}
}

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