Compare commits

...

485 Commits

Author SHA1 Message Date
Vitor Pamplona
8886ad83e0 v0.17.3: Tentative bugfix for crashes in Notification and Search screens 2023-02-20 20:54:48 -05:00
Vitor Pamplona
e52d7a7881 Adding some space between the profile picture and the name for quoted notes 2023-02-20 20:51:27 -05:00
Vitor Pamplona
d31c11f70d Checking HexKeys across the board.. Too many errors in tags. 2023-02-20 20:49:54 -05:00
Vitor Pamplona
a153f577c9 0.17.2: Quote Note Popup And Rendering 2023-02-20 18:10:40 -05:00
Vitor Pamplona
9213394731 Rendering Quoted Noted 2023-02-20 18:09:57 -05:00
Vitor Pamplona
a24e6391dc Maxing out post size when on Twitter-like interfaces. 2023-02-20 17:36:20 -05:00
Vitor Pamplona
dbcd5ed7fe Quote Note support 2023-02-20 17:28:58 -05:00
Vitor Pamplona
1f2e120851 Trying to hunt down missing notifications. 2023-02-20 17:27:40 -05:00
Vitor Pamplona
88432d4ab6 Notification Note background is now clickable. 2023-02-20 16:39:02 -05:00
Vitor Pamplona
7e2b8397e7 Merge pull request #142 from terryschmidt/leak_canary
add leak canary to detect memory leaks in debug builds
2023-02-20 15:19:20 -05:00
Vitor Pamplona
3b990c5f2d Merge pull request #141 from terryschmidt/update_gradle
update gradle
2023-02-20 15:18:18 -05:00
Vitor Pamplona
8b70a11754 Merge remote-tracking branch 'origin/main' 2023-02-20 15:17:50 -05:00
Vitor Pamplona
f36906f6c0 Merge pull request #140 from terryschmidt/fix_app_name_error
fix redundant android:label in AndroidManifest.xml
2023-02-20 15:17:11 -05:00
Vitor Pamplona
19cc0d1ce8 Shows comma in some keyboards 2023-02-20 14:58:05 -05:00
Vitor Pamplona
1f953cb728 Fixes changing editing amounts on recompose. 2023-02-20 14:57:53 -05:00
Vitor Pamplona
70c20fbb6d Resets mentions to avoid marking users in new posts from an old reply. 2023-02-20 14:41:50 -05:00
Vitor Pamplona
8db743c1e3 Fixes the "Show Anyway" button not working in the first element of the Thread view 2023-02-20 14:41:32 -05:00
Vitor Pamplona
51061250e2 0v.17.1 2023-02-20 13:33:15 -05:00
Vitor Pamplona
ddd35c5119 Custom Zap Amounts. 2023-02-20 13:23:44 -05:00
Vitor Pamplona
dcc84f4572 Sets Search to SingleLine only 2023-02-20 13:23:24 -05:00
Vitor Pamplona
0236837856 Displays lnurl setups as lightning addresses in the Profile page. 2023-02-20 12:09:25 -05:00
Vitor Pamplona
892214ec27 Pays zaps through LNURLs / lud06 fields 2023-02-20 12:09:00 -05:00
Terry Schmidt
dd404efc7d fix redundant android:label warning 2023-02-20 11:44:56 -05:00
Terry Schmidt
2bd3792b8f add leak canary to detect memory leaks in debug builds 2023-02-20 01:34:01 -05:00
Terry Schmidt
ea18b5d5cc update gradle 2023-02-20 01:30:07 -05:00
Vitor Pamplona
2eae1ad434 v0.17.0 2023-02-19 16:40:46 -05:00
Vitor Pamplona
03dd09e957 Adds a second list of filtered Users that were automatically added and are not saved with the person's key 2023-02-19 16:38:59 -05:00
Vitor Pamplona
f34668b90d Moves processing of new events from IO to Default 2023-02-19 15:38:08 -05:00
Vitor Pamplona
504f9cf978 Removes subscriptions from the list once closed. 2023-02-19 15:37:44 -05:00
Vitor Pamplona
182ac5de07 Changes the follower calculation to only store when important to the user. 2023-02-19 15:37:26 -05:00
Vitor Pamplona
6063bd789d Adds anti-spam filters 2023-02-19 15:36:43 -05:00
Vitor Pamplona
c610d130f8 Pruning Messages from Hidden Users. 2023-02-19 15:36:00 -05:00
Vitor Pamplona
23a5e9e66b Memory Pruning: Only keeping 1000 messages per channel 2023-02-19 12:40:19 -05:00
Vitor Pamplona
d57fca3549 Splits profile between new threads and replies 2023-02-19 11:34:45 -05:00
Vitor Pamplona
8bc5aab880 Uses 1/3 of the memory per not-fully loaded user/note
BugFix for invalid Hexes in mentions, contact lists, etc.
2023-02-19 11:22:01 -05:00
Vitor Pamplona
71b86ea69c Logo in transparent PNG 2023-02-18 19:21:02 -05:00
Vitor Pamplona
71dad0c69e Fixes missing elements in the previous commit 2023-02-18 19:20:39 -05:00
Vitor Pamplona
b8cb6295f4 Goes back to considering the full list of results when assessing changes to the visible items. 2023-02-18 19:15:24 -05:00
Vitor Pamplona
666972fd62 Displays the amount and list of reports in the Profile page. 2023-02-18 19:14:52 -05:00
Vitor Pamplona
0914a7b68c Adds a lastReportedTime field (to be used with Since in the websocket filter) for Users with too many reports. 2023-02-18 19:14:27 -05:00
Vitor Pamplona
de2ec503da Keeps Note level in a thread cached for ordering notes. 2023-02-18 19:13:43 -05:00
Vitor Pamplona
53a14d3a77 Optimizes the UserProfile datasource to remove the filter when the user leaves the profile screen. 2023-02-18 19:12:21 -05:00
Vitor Pamplona
dfe95c28ea Displays debug information on how many notes and users where fully loaded to the phone as a % of total references (pubkeys, ids) stored 2023-02-18 19:11:10 -05:00
Vitor Pamplona
bf9f4202d4 Display follows in the reverse order (latest follows first) 2023-02-18 19:10:31 -05:00
Vitor Pamplona
54cdb5ae68 Removes Cited Posts from the replies (Fixes Quoted Notes) 2023-02-18 19:09:49 -05:00
Vitor Pamplona
6ff474376f Stops User Profile Websocket when pausing the app. 2023-02-18 19:09:16 -05:00
Vitor Pamplona
138b22348c Makes sure Websocket dispatchers are using IO threads 2023-02-18 19:08:52 -05:00
Vitor Pamplona
2ac5174b78 Fixes more places where Android's coroutine cancellations might affect a mutex. 2023-02-18 19:07:58 -05:00
Vitor Pamplona
89f714b1ad Refactors thread assembler to reduce number of lists used. 2023-02-18 19:07:16 -05:00
Vitor Pamplona
d2c39b022b Closes subscriptions regardless with they are active or not. 2023-02-18 19:06:39 -05:00
Vitor Pamplona
ccd6beee8e Reduces the amount of Reports downloaded when observing single users. 2023-02-18 19:06:05 -05:00
Vitor Pamplona
b7c72a282f Allows Reported messages to appear (with warning) in notifications. 2023-02-18 19:05:33 -05:00
Vitor Pamplona
0d1bf72708 Fixes Notification for messaging with yourself. 2023-02-18 19:04:56 -05:00
Vitor Pamplona
34bdc2b103 Fixes Mutex when Android cancels coroutines. 2023-02-18 19:04:03 -05:00
Vitor Pamplona
cc8866d240 Fixing Debug messages. 2023-02-18 19:02:47 -05:00
Vitor Pamplona
b60d7ff857 Trimming User Metadata fields to avoid crashes when using them 2023-02-18 14:05:46 -05:00
Vitor Pamplona
4f53a74004 Massive Refactoring:
1. Split between Relay Filters and Screen Filters.
2. Moving Notification dots to background threads.
3. Loading new posts on ThreadView on the fly.
2023-02-18 13:06:53 -05:00
Vitor Pamplona
f0e09197ff Avoids starting connections with default relays when resuming the app. 2023-02-17 12:27:36 -05:00
Vitor Pamplona
4afcf48392 v0.16.2 2023-02-16 19:43:33 -05:00
Vitor Pamplona
943d4525be Removing favicons from proxy 2023-02-16 19:41:50 -05:00
Vitor Pamplona
de302eb966 Improving error count for relays to also include wrong URLs 2023-02-16 19:41:31 -05:00
Vitor Pamplona
85adc722ae v0.16.1 2023-02-16 19:04:26 -05:00
Vitor Pamplona
c8c48cca32 BugFix: User's relays not coming back after a pause of the app if they have not setup relays through the app yet. 2023-02-16 19:03:11 -05:00
Vitor Pamplona
3f68b42acd Moving Async Image Proxy to components. 2023-02-16 18:25:55 -05:00
Vitor Pamplona
da5c61038d Fixes https://github.com/vitorpamplona/amethyst/issues/129 2023-02-16 08:17:41 -05:00
Vitor Pamplona
e8949f08ae Asks reports when each user/event is seen instead. 2023-02-15 17:20:39 -05:00
Vitor Pamplona
068b5faf7d Remove logs 2023-02-15 17:19:55 -05:00
Vitor Pamplona
7dd01ae5e4 v0.16.0 2023-02-15 12:31:28 -05:00
Vitor Pamplona
3493802a1e Activating Image Proxy + CDN 2023-02-15 12:31:15 -05:00
Vitor Pamplona
9ec9feda28 v0.15.9 2023-02-14 20:34:27 -05:00
Vitor Pamplona
0277fa7364 Fixing the delay in updating the Note tab on the Profile View 2023-02-14 20:33:32 -05:00
Vitor Pamplona
af96f142cd Activating Event's check signature 2023-02-14 20:32:35 -05:00
Vitor Pamplona
f80ba653b2 v0.15.8 2023-02-14 19:23:33 -05:00
Vitor Pamplona
5947d03cb3 Follower Count bug 2023-02-14 19:22:37 -05:00
Vitor Pamplona
694cfdf322 v0.15.7 2023-02-14 18:21:57 -05:00
Vitor Pamplona
10efe5d17d Makes chat bubbles back to their contentWrap state 2023-02-14 18:15:33 -05:00
Vitor Pamplona
d964f93e67 Adds helper classes to avoid recreating master lists. 2023-02-14 17:44:03 -05:00
Vitor Pamplona
4996c5a9d8 Only adds a report if it doesn't already exist in the database 2023-02-14 17:43:19 -05:00
Vitor Pamplona
c0fe13d79c Only wait 5 seconds to update lists if contents are already been displayed. 2023-02-14 17:42:35 -05:00
Vitor Pamplona
c56d3af0fc v0.10.6 2023-02-14 16:13:11 -05:00
Vitor Pamplona
a050236d6a Increasing Delays to update lists to 5 seconds while making sure it refreshes quickly when the user switches the tab. 2023-02-14 16:02:38 -05:00
Vitor Pamplona
90bdeffc52 v0.15.5 Performance Improvements. 2023-02-14 14:26:28 -05:00
Vitor Pamplona
e3ed676624 Avoids using remember for Fixed avatars. 2023-02-14 14:25:26 -05:00
Vitor Pamplona
5e5ea32be1 Better fixed avatar handling. 2023-02-14 14:25:01 -05:00
Vitor Pamplona
1306270887 Update lists once every second, not 100ms 2023-02-14 14:24:50 -05:00
Vitor Pamplona
fbff78208e Making sure screens are refreshed from the start 2023-02-14 14:24:14 -05:00
Vitor Pamplona
8468212440 v0.15.4 2023-02-14 13:35:55 -05:00
Vitor Pamplona
7f5a173259 Adding more information to the debug info 2023-02-14 13:34:59 -05:00
Vitor Pamplona
1b8d83f41a Increasing Image disk cache size to 1GB 2023-02-14 13:34:45 -05:00
Vitor Pamplona
e381e2f05f Adjusting Post Lists to 1000 and simplifying comparisons to know when the list has changed. 2023-02-14 12:08:35 -05:00
Vitor Pamplona
6a05039bde Fixing Zap counter in the Profile Page. 2023-02-14 11:48:17 -05:00
Vitor Pamplona
76737e6b74 Adding Zaps to the profile 2023-02-14 11:37:41 -05:00
Vitor Pamplona
c3aa37534e Removing the Duplicated Observer infrastructure on User: It wasn't updating observers correctly due to the batch update we use. 2023-02-14 11:01:08 -05:00
Vitor Pamplona
8e897762dd Getting Relay icons from non SSL-secured relays 2023-02-14 10:58:42 -05:00
Vitor Pamplona
790cbb35db Moving Database updates to an invalidate UI call that only runs at every 100 ms. 2023-02-14 09:29:24 -05:00
Vitor Pamplona
743c23f3c8 Moving Incoming Payload processing to the Global Thread to release Web Threads faster. 2023-02-14 09:17:12 -05:00
Vitor Pamplona
385a1c3849 Merge pull request #122 from veer66/main
Fix issue #121
2023-02-14 08:25:00 -05:00
Vee Satayamas
a3a3f8ea99 Fix issue #121
NIP-09 is event deletion, not delegation
2023-02-14 10:49:30 +07:00
Vitor Pamplona
25b470cde6 Removing border colors. 2023-02-13 18:10:13 -05:00
Vitor Pamplona
05d71fd27e Better positioning of the User Search (always at the bottom, independent of screen size) 2023-02-13 18:08:38 -05:00
Vitor Pamplona
f5e61b9409 v0.15.3 2023-02-13 17:12:57 -05:00
Vitor Pamplona
620bd4d5a8 This might work best between smaller images and bigger images 2023-02-13 17:12:19 -05:00
Vitor Pamplona
3f671350ce Adjusting the New Post screen to show Search and Images (cropped if necessary) together with the keyboard and the text. 2023-02-13 16:48:42 -05:00
Vitor Pamplona
b89a8e5f8f v0.15.2 2023-02-13 16:14:17 -05:00
Vitor Pamplona
a01a282e2e Adjusting New Post screen's keyboard cutting off in long posts 2023-02-13 16:13:47 -05:00
Vitor Pamplona
2ed0ca5fdb Removing DataSource stops in ViewModels to make sure this is handled by the Screen class 2023-02-13 15:50:50 -05:00
Vitor Pamplona
49366ac47d Speeding up report queries 2023-02-13 15:49:51 -05:00
Vitor Pamplona
2ac4ed8ac4 Merge pull request #118 from Radiokot/fix/gif_image_upload
Fix GIF image upload #115
2023-02-13 09:40:46 -05:00
Oleg Koretsky
bb76bbd313 Show running image uploading. Show error message if the uploading is failed 2023-02-13 16:24:49 +02:00
Vitor Pamplona
2c5ba69657 v0.15.1 2023-02-13 08:54:23 -05:00
Vitor Pamplona
a618e84d32 Image seems to better support Bitmap models than Async Image 2023-02-13 08:45:32 -05:00
Oleg Koretsky
108ad4dadc Do not encode images to JPEG before uploading. Fixes GIF upload #115 2023-02-13 11:57:26 +02:00
Vitor Pamplona
80bede648f Moving the ThreadView to the same Model as Global: Only active when using this view. 2023-02-12 21:53:22 -05:00
Vitor Pamplona
e5ee367761 Doesn't start the Global to make sure home updates faster. 2023-02-12 21:48:07 -05:00
Vitor Pamplona
4e53a9fda0 Removes unnecessary println 2023-02-12 21:47:53 -05:00
Vitor Pamplona
caa170f78e Forces profile screen and search screen to only be active when in such views. 2023-02-12 21:47:38 -05:00
Vitor Pamplona
a857b587a1 Forces the use of a single RoboHash for performance 2023-02-12 21:47:02 -05:00
Vitor Pamplona
420bacea3d Forcing the Account Datasource to only download notifications (Removing new followers, for instance) 2023-02-12 21:46:40 -05:00
Vitor Pamplona
b2da651fd1 Clears filters when DataSource closes 2023-02-12 21:44:18 -05:00
Vitor Pamplona
04d94b4dbf Removes Raw ZapNotes from Notifications 2023-02-12 21:43:19 -05:00
Vitor Pamplona
64f2575228 Lightning Zaps. 2023-02-12 18:23:02 -05:00
Vitor Pamplona
dc07aea525 Merge remote-tracking branch 'origin/main' 2023-02-11 10:34:09 -05:00
Vitor Pamplona
21ac1542ee Add a logo option with borders for the play store. 2023-02-11 10:33:56 -05:00
Vitor Pamplona
9cd90db6b3 Update README.md 2023-02-11 10:16:50 -05:00
Vitor Pamplona
8a65f3fbe7 Moving channels to the local version of RoboHash 2023-02-10 17:29:10 -05:00
Vitor Pamplona
e6153e7ef5 Enabling local RoboHash for User Pictures 2023-02-10 17:14:34 -05:00
Vitor Pamplona
c62c4c7545 Reconnect after 60 seconds instead of 10 seconds. 2023-02-10 17:14:08 -05:00
Vitor Pamplona
c4d3c105f7 Better content descriptors for UrlPreviewCard 2023-02-10 17:13:51 -05:00
Vitor Pamplona
3d3578666d Using a local version of RoboHash 2023-02-10 17:13:25 -05:00
Vitor Pamplona
01c060caa1 Caching videos to reduce initialization delay. 2023-02-10 14:30:39 -05:00
Vitor Pamplona
d6cd8e2804 v0.14.3 2023-02-10 11:56:02 -05:00
Vitor Pamplona
fa559c8428 Disabling ProGuard until we figure out why crashes only happen in the release build. 2023-02-10 11:55:31 -05:00
Vitor Pamplona
a38087a9b9 v0.14.2 2023-02-10 09:13:45 -05:00
Vitor Pamplona
29dc677148 Allows the user to leave and join their own groups 2023-02-10 09:13:01 -05:00
Vitor Pamplona
50bc55e84b More Performant UpdateFollows 2023-02-09 17:52:18 -05:00
Vitor Pamplona
ae8d39df92 Download only metadata in the Search for authors 2023-02-09 16:42:30 -05:00
Vitor Pamplona
09919e99b9 Fixes: https://github.com/vitorpamplona/amethyst/issues/91 2023-02-09 15:25:32 -05:00
Vitor Pamplona
5069c30a4a Fixes https://github.com/vitorpamplona/amethyst/issues/103 Doesn't do anything if the content in the field is not a URI. 2023-02-09 15:11:20 -05:00
Vitor Pamplona
4097263c12 Activating Proguard 2023-02-09 15:02:13 -05:00
Vitor Pamplona
88e91e37fa Fixing the height of the nav bar icon 2023-02-09 14:56:54 -05:00
Vitor Pamplona
a003e111eb Fixing QR Code screen for smaller screens. 2023-02-09 14:45:00 -05:00
Vitor Pamplona
4cde2fe8e6 Refactoring of RelaySetupInfo away from ModelView 2023-02-09 14:25:58 -05:00
Vitor Pamplona
ffe5e79474 Making sure once proguard is used, it doesn't break json serialization 2023-02-09 14:24:56 -05:00
Vitor Pamplona
19a9e7a949 language-id seems necessary for some phones. unsure why 2023-02-09 14:24:28 -05:00
Vitor Pamplona
757e566774 Fixes permissions issue in Image Upload 2023-02-09 11:22:49 -05:00
Vitor Pamplona
7fff535b74 v0.14.1:
- Save image
- LNURL edit in the profile
- Bugfix crashing the Lightning Tips with non numeric values in the amount.
2023-02-09 10:42:08 -05:00
Vitor Pamplona
65ef34511a Adding LNURL setup in the User Metadata 2023-02-09 10:36:43 -05:00
Vitor Pamplona
644c6b4d76 Merge remote-tracking branch 'origin/main' 2023-02-09 09:47:34 -05:00
Vitor Pamplona
dec17e162e Avoids crashing on a non-numeric amount in Lightning Tips 2023-02-09 09:47:23 -05:00
Vitor Pamplona
df49c63936 Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
2023-02-09 09:22:46 -05:00
Vitor Pamplona
f0d4ad38a5 Merge pull request #98 from bleetube/offchain.pub
Public relay name change
2023-02-09 09:22:27 -05:00
Vitor Pamplona
7cb146bb50 v0.14.0 2023-02-08 18:27:23 -05:00
Vitor Pamplona
5195ddf63e Sending Lightning Tips 2023-02-08 18:26:49 -05:00
Oleg Koretsky
3fde6b4b1f Add Save option to the image view screen 2023-02-08 22:06:12 +02:00
Brian Lee
5102a15532 Public relay name change 2023-02-08 12:05:56 -08:00
Vitor Pamplona
00981ef15c Refactoring RichTextViewer file 2023-02-08 14:02:42 -05:00
Vitor Pamplona
f2badce3b8 v0.13.3 2023-02-08 13:55:25 -05:00
Vitor Pamplona
e9f0fb82e9 New options to choose which language to translate to and which languages to block translations from 2023-02-08 11:57:36 -05:00
Vitor Pamplona
abf217b71d Fixes: https://github.com/vitorpamplona/amethyst/issues/95 2023-02-07 18:03:48 -05:00
Vitor Pamplona
9faf6a2fbc Merge pull request #94 from Radiokot/fix/ui_issues
Fix some UI issues
2023-02-07 15:59:26 -05:00
Vitor Pamplona
f61b894ddf Merge branch 'main' into fix/ui_issues 2023-02-07 15:59:19 -05:00
Vitor Pamplona
b0dda37023 Merge pull request #90 from greenart7c3/main
fix dropDown color on light theme
2023-02-07 15:56:17 -05:00
Oleg Koretsky
d600d1c3e5 Fix invisible user profile menu button (white on white) 2023-02-07 12:01:23 +02:00
Oleg Koretsky
79947524cc Fix login screen buttons crop when the keyboard is opened 2023-02-07 11:53:46 +02:00
Oleg Koretsky
2e49bbc606 Make post translation label wrap words properly, fix #86 2023-02-07 11:20:45 +02:00
Vitor Pamplona
c3f0f7c59c Bugfix 2023-02-06 18:53:43 -05:00
Vitor Pamplona
387c5cdffa v0.13.2 2023-02-06 18:39:45 -05:00
Vitor Pamplona
660f11cfc4 Adjustments to video playback 2023-02-06 18:38:59 -05:00
Vitor Pamplona
2a6a6f459c Using the explore button to present information about the relay 2023-02-06 18:15:54 -05:00
Vitor Pamplona
12c330f8f6 Correctly handling Atomic Boolean 2023-02-06 18:15:37 -05:00
Vitor Pamplona
d252297ba4 List relays in Private DMs 2023-02-06 18:15:18 -05:00
Vitor Pamplona
ba5eca648f Adds individual relay information per chat message. 2023-02-06 17:51:55 -05:00
Vitor Pamplona
ca3ed88b78 Tracks relays used per Note 2023-02-06 17:16:27 -05:00
Vitor Pamplona
433881fa21 Adjusts final relays for release 2023-02-06 17:16:07 -05:00
greenart7c3
3a7228712c fix dropDown color on light theme 2023-02-06 17:30:35 -03:00
Vitor Pamplona
7b3a23825f Updating Semisol's relay. 2023-02-06 14:50:59 -05:00
Vitor Pamplona
2819c7efcf Shipping Amethyst with Paid relays for Global and Public Chats. 2023-02-06 14:34:44 -05:00
Vitor Pamplona
e645029ddf Crops Posts in 350 chars. 2023-02-06 14:34:21 -05:00
Vitor Pamplona
f160a024aa Since refresh is mostly a CPU/Memory process and not really IO, Default is recommended. 2023-02-06 12:29:17 -05:00
Vitor Pamplona
074cac3021 Removes unnecessary elvis operator 2023-02-06 12:28:27 -05:00
Vitor Pamplona
e7c6292cdc Fixing test case after https://github.com/vitorpamplona/amethyst/pull/87 2023-02-06 12:18:52 -05:00
Vitor Pamplona
6daeb487d3 Fixing unassignable variable from PR https://github.com/vitorpamplona/amethyst/pull/88 2023-02-06 11:30:57 -05:00
Vitor Pamplona
2e2e54d642 Merge pull request #87 from greenart7c3/main
add debug configuration
2023-02-06 11:19:25 -05:00
Vitor Pamplona
d2ce108bf3 Merge pull request #88 from middlingphys/remove_trailing_slash
remove trailing slash from relay URI
2023-02-06 11:18:48 -05:00
Vitor Pamplona
a6dcf64f04 Refactoring Channel -> Subscription 2023-02-06 10:56:10 -05:00
middlingphys
a2f85dd060 remove trailing slash from addedWSS 2023-02-06 23:25:45 +09:00
greenart7c3
7c8c79819a add debug configuration 2023-02-06 11:15:18 -03:00
Vitor Pamplona
a5f156ec49 Merge remote-tracking branch 'origin/main' 2023-02-06 08:38:48 -05:00
Vitor Pamplona
43a6fefa17 Merge pull request #84 from Radiokot/fix/83
Fix #83
2023-02-06 08:38:10 -05:00
Vitor Pamplona
de3431e374 Fixing the order of url text and link 2023-02-06 08:32:35 -05:00
Oleg Koretsky
09f3a440d3 Fix switching tabs when changing the pager height 2023-02-06 14:46:54 +02:00
Oleg Koretsky
49c93c11e6 Make the profile header scrollable a, keep the bs â fixed 2023-02-06 13:47:48 +02:00
Vitor Pamplona
8ce96257d2 v0.13.1 2023-02-05 20:26:47 -05:00
Vitor Pamplona
94b525dd1f Fixing add button color. 2023-02-05 18:55:09 -05:00
Vitor Pamplona
e04843fe96 Using Main to update screen seems needed otherwise I get a 1 minute delay in the loading page before the screen updates. It looks like it waits for all the Pictures to load. Not sure why. 2023-02-05 18:39:54 -05:00
Vitor Pamplona
bb50099021 Merge pull request #75 from clackbib/habib/threading-perf-fixes
Scroll Perf Improvements.
2023-02-05 18:18:29 -05:00
Vitor Pamplona
459c195113 Merge pull request #76 from clackbib/habib/search-perf
A couple proposed improvements to search.
2023-02-05 18:16:58 -05:00
Vitor Pamplona
933fa9bf4d Merge pull request #77 from clackbib/habib/scrollabletabs
Tiny change to profile page:
2023-02-05 18:14:59 -05:00
Vitor Pamplona
bd94544c9b Breaking filters down by feed type: Home, DMs, Public Chats and Global. 2023-02-05 18:14:41 -05:00
Vitor Pamplona
72aad26c03 Fixing a bug where the last message is remembered by the Compose when forcing index = 0 on the first element. 2023-02-05 18:13:08 -05:00
Vitor Pamplona
ff087e3dea Correctly addressing Preview of images when posts have been reported. 2023-02-05 18:12:11 -05:00
Vitor Pamplona
eb41f22bae Correctly addresses a report to a note (and not to a user) 2023-02-05 18:10:32 -05:00
Vitor Pamplona
f1a37afaf1 Testing to see if the use of PlayServices dependencies uses less cache from the app. 2023-02-05 18:09:49 -05:00
Vitor Pamplona
9035fda17a Simplifying dependencies 2023-02-05 12:59:35 -05:00
Vitor Pamplona
c16f175ebc Not display any previews if the note has a report. 2023-02-05 12:59:15 -05:00
Vitor Pamplona
5c5aa222c5 Quick refactoring 2023-02-05 12:58:26 -05:00
Habib Okanla
14bda01a70 Tiny change to profile page:
Use scrollable tabs instead of fixed tab to avoid bad text wrap.
2023-02-05 02:05:54 -05:00
Habib Okanla
cd9f359add A couple proposed improvements to search.
The UX and performance of search is relatively poor because each keystroke triggers a search & update. With this changes:

1. Search queries are moved into an IO thread so that typing is smooth and data sends happend in the background.

2. Text changed are debounced by 300 millis, meaning search will not be fired off on every keystroke, but rather only when we the user stops typing for 300 millis.
2023-02-05 01:56:16 -05:00
Habib Okanla
f40060bb36 There are very visible perf issues during app usage and scrolling that clearly indicates that the app is doing too much on the main thread. After digging for instances where Dispatchers.Main is used, it's an easy fix to switch to Dispatchers.IO, which visibility improve perf.
A few thoughts about perf considerations

1. There is no need to force Dispatchers.Main for data that is consumed as state by compose, since flows consumed as state will always flow on main, so we can use a background thread to guarantee best performance.

2. Using Dispatchers.IO is appropriate for disk/network operations to have a device-constrained thread pool that will avoid draining IO-related device resources. Using Dispatchers.Default is more appropriate for computational tasks (bitmap manipulation, delays, etc..)

3. There are a few instances of methods creating coroutine scopes in their body just to launch something that will delay. This is creating a lot of loose scopes, and you can avoid this by just moving scope creation to a class-level field and reusing it, or better yet, make your method suspending so that scope is controlled by the caller.
2023-02-05 00:41:37 -05:00
Vitor Pamplona
c5aa57d91b v0.13.0 2023-02-04 19:44:47 -05:00
Vitor Pamplona
229f15ee7f Adds automatic translation to feed and chat. 2023-02-04 19:43:31 -05:00
Vitor Pamplona
d168a6c861 Changes to Logging 2023-02-04 19:43:08 -05:00
Vitor Pamplona
39d58452a0 Allows redirection on relay URIs 2023-02-04 19:42:08 -05:00
Vitor Pamplona
b334c7105c Adds a relay filter to download all reports by the user 2023-02-04 19:41:47 -05:00
Vitor Pamplona
ea9ff08d8a Adds hidden notes to chat. 2023-02-04 19:41:19 -05:00
Vitor Pamplona
e3d7c23218 Moves QR code classes to another package 2023-02-04 19:40:45 -05:00
Vitor Pamplona
67b030f068 Matches target API with build file 2023-02-04 15:10:16 -05:00
Vitor Pamplona
1940303130 Increases timeout period and avoid double websocket connections. 2023-02-04 13:32:09 -05:00
Vitor Pamplona
80bc09df92 refactoring 2023-02-04 13:30:39 -05:00
Vitor Pamplona
ea59568b6f removes mutable list in Client. 2023-02-04 13:30:15 -05:00
Vitor Pamplona
3e08256347 Keeping subscription names small to please some relays. 2023-02-04 13:29:53 -05:00
Vitor Pamplona
2641dda7bc Fixes Follow/Unfollow button update after pressing. 2023-02-04 10:48:54 -05:00
Vitor Pamplona
40deb46eab Bugfix: consider flags of the referenced post in case of Boost events. 2023-02-04 10:42:32 -05:00
Vitor Pamplona
2a5f8595d0 Increasing Notification stack to 200 2023-02-04 10:41:58 -05:00
Vitor Pamplona
6c27fe0828 Adding more info into flagged posts. 2023-02-04 10:41:43 -05:00
Vitor Pamplona
2992d9daf0 Trying to fix NPE around these lines. 2023-02-04 10:41:01 -05:00
Vitor Pamplona
bbfd060328 v0.12.1 2023-02-03 17:46:24 -05:00
Vitor Pamplona
9e894642ec Correctly aligns chat field. 2023-02-03 17:45:44 -05:00
Vitor Pamplona
22daf1a6a8 Removes Channels from the Messages Notification Dot. 2023-02-03 17:45:28 -05:00
Vitor Pamplona
1a3b92a727 Abolishes the use of Mutable collections inside data models. 2023-02-03 17:23:47 -05:00
Vitor Pamplona
ee7120d803 Adding a way to visualize view count stats 2023-02-03 11:26:49 -05:00
Vitor Pamplona
a45a8789e7 Faster animations 2023-02-03 11:26:28 -05:00
Vitor Pamplona
bb3e38d079 Merge pull request #63 from clackbib/disable_backup
Disable android backup as this app uses EncryptedSharedPrefs. Usage o…
2023-02-03 09:46:54 -05:00
Vitor Pamplona
1568cad693 Fixing test cases 2023-02-03 09:08:06 -05:00
Vitor Pamplona
10e5c43be9 v0.12.0 2023-02-02 20:40:03 -05:00
Vitor Pamplona
e3aa573b8c Removing logs 2023-02-02 20:39:54 -05:00
Vitor Pamplona
bd77c43d40 Not reloading reactions to an event. 2023-02-02 20:18:06 -05:00
Vitor Pamplona
8e02f67052 Reducing the chance of a bug when loading chatroom without a user 2023-02-02 20:17:53 -05:00
Vitor Pamplona
299cf1aabb Keeping the report filter. 2023-02-02 20:16:35 -05:00
Vitor Pamplona
343521ff58 Cut off of 200 messages for each channel 2023-02-02 20:16:07 -05:00
Vitor Pamplona
a82f260142 Capturing EOSE events and keeping messages within relay limits (10 subscriptions and 10 REQ events at a time) 2023-02-02 16:57:03 -05:00
Vitor Pamplona
fb73308995 Moving observable classes to the leaf nodes. 2023-02-02 14:34:34 -05:00
Vitor Pamplona
b3ce10c2de Allowing HTTP downloads 2023-02-02 14:30:58 -05:00
Vitor Pamplona
4fa6873180 Forcing Notifications with Direct citations only removes too much. 2023-02-02 14:29:39 -05:00
Habib Okanla
a34e88b4fc Disable android backup as this app uses EncryptedSharedPrefs. Usage of EncryptedSharedPrefs can cause crashes if it's not properly excluded from backup.
https://stackoverflow.com/questions/61751264/autobackup-with-encryptedsharedpreferences-failing-to-restore
2023-02-01 19:33:46 -05:00
Vitor Pamplona
912ca72d68 Presenting a Drop Down menu to copy keys and channel IDs. 2023-02-01 14:05:59 -03:00
Vitor Pamplona
c3c19ebb49 Only showing notifications if it directly cites the account holder 2023-02-01 13:51:03 -03:00
Vitor Pamplona
0f968ca5e5 Removing channel create and change from Notifications Feed 2023-02-01 13:17:21 -03:00
Vitor Pamplona
2e17535a60 Keeping chatroom list scroll up top if it was up top. 2023-02-01 12:40:31 -03:00
Vitor Pamplona
663eb9c604 Solves https://github.com/vitorpamplona/amethyst/issues/58 2023-02-01 10:52:54 -03:00
Vitor Pamplona
4b894c5b33 Fixes bug where default relays would come back after leaving the two groups and not adding any other group. 2023-01-31 23:42:14 -03:00
Vitor Pamplona
814fb1845f Moving Picture and Display Name Observables to leaf UI nodes. 2023-01-31 22:12:24 -03:00
Vitor Pamplona
94a4bfc2f3 Avoiding calculations in the UI thread 2023-01-31 22:11:54 -03:00
Vitor Pamplona
4303e6040c Fixes Channel @tag 2023-01-31 20:50:56 -03:00
Vitor Pamplona
75cb313886 Uses Hex to idex Users 2023-01-31 20:50:38 -03:00
Vitor Pamplona
88d2786473 v0.11.7 2023-01-30 23:39:30 -03:00
Vitor Pamplona
7837860ac6 Keep Note model refreshes in the Default thread. 2023-01-30 23:39:09 -03:00
Vitor Pamplona
b7bc8a0b9d Fixing Boost to add content of the post in the boosted event as well as selecting only the last one to show on the screen. 2023-01-30 23:36:06 -03:00
Vitor Pamplona
c71c121356 Correctly updates new changes to the User model 2023-01-30 22:41:13 -03:00
Vitor Pamplona
d94f35de0e User Relay information in Profiles 2023-01-30 22:05:48 -03:00
Vitor Pamplona
3c52ff6e8d Tracks where notes are coming from 2023-01-30 19:38:06 -03:00
Vitor Pamplona
dc47845dd6 Solves a duplicated id in the feed. 2023-01-30 19:36:39 -03:00
Vitor Pamplona
76087e5f25 Removes new threads, boosts from the Conversations view 2023-01-30 13:51:30 -03:00
Vitor Pamplona
6225b4f843 Renames Follows + Replies to Conversations 2023-01-30 13:51:10 -03:00
Vitor Pamplona
3a834a932c Increases Home feed to 400 2023-01-30 13:50:56 -03:00
Vitor Pamplona
e59dfa7a76 Avoids concurrent exceptions 2023-01-30 13:50:43 -03:00
Vitor Pamplona
a0ba313661 Creating linkable routes for NIP19 in content. 2023-01-30 13:50:24 -03:00
Vitor Pamplona
fb8d6daf3d Resizing when using the keyboard 2023-01-30 00:23:22 -03:00
Vitor Pamplona
f2d7dea860 v0.11.6 Avoids crashing on invalid relay entries. 2023-01-29 23:35:08 -03:00
Vitor Pamplona
29b1a94ec4 Fixing crash due to wrong relay url (spaces, wrong schema, etc) 2023-01-29 23:27:48 -03:00
Vitor Pamplona
bba5afcdbf 0.11.5 2023-01-29 22:07:00 -03:00
Vitor Pamplona
8b1e0f9af0 Activating NIP 56 (Report Users and Posts with Event Kind 1984) 2023-01-29 22:06:48 -03:00
Vitor Pamplona
9fe73c7a97 v0.11.4 QR code reading and scanning (any NIP-21 uri) 2023-01-29 00:58:43 -03:00
Vitor Pamplona
9263684031 Support for PubKey QR showing and Scanning. 2023-01-29 00:56:13 -03:00
Vitor Pamplona
d1753a59f9 v0.11.3 2023-01-28 12:42:37 -03:00
Vitor Pamplona
31e2e586b7 Adding Block user option in profiles. 2023-01-28 12:42:24 -03:00
Vitor Pamplona
5a502f3b80 v0.11.2 2023-01-27 22:09:45 -03:00
Vitor Pamplona
456d40c58e Declaring observers directly into the Picture Compose 2023-01-27 22:05:22 -03:00
Vitor Pamplona
d19a1fa1b2 Refactoring 2023-01-27 22:04:59 -03:00
Vitor Pamplona
59dc19720f Correctly filtering out the channel id from the reply to list 2023-01-27 22:04:45 -03:00
Vitor Pamplona
11cfc90d36 Remove Channel Messages from event datasource 2023-01-27 22:04:25 -03:00
Vitor Pamplona
9f2f0727a0 Increase Global size to 200 2023-01-27 22:04:03 -03:00
Vitor Pamplona
6f9a69b9d4 Migrating pictures and banners to https 2023-01-27 22:03:54 -03:00
Vitor Pamplona
5ad39d7163 Clickable Profile pictures on notifications 2023-01-27 20:29:09 -03:00
Vitor Pamplona
acff1316be Only disk is much slower 2023-01-27 20:28:30 -03:00
Vitor Pamplona
ee2684a775 Printing memory and disk use for cache 2023-01-27 20:28:01 -03:00
Vitor Pamplona
5f3f80bc97 Waits 10 seconds before reconnecting. 2023-01-27 20:06:34 -03:00
Vitor Pamplona
1e3654396b No more blinking in Feeds 2023-01-27 19:28:59 -03:00
Vitor Pamplona
ae82c690ea Refreshing new Channels when showing them up on screen. 2023-01-27 19:28:48 -03:00
Vitor Pamplona
a29a0a614b Profile Refactoring 2023-01-27 16:10:13 -03:00
Vitor Pamplona
4d5a40f4ac Avoids any crash in processing events. 2023-01-27 16:10:05 -03:00
Vitor Pamplona
27e12a63d6 Full integration between Channel Messages and Regular notes. 2023-01-27 15:50:17 -03:00
Vitor Pamplona
a934b42524 Avoiding concurrent exceptions when looping through messages 2023-01-27 14:50:20 -03:00
Vitor Pamplona
c29c14f82b Round input texts 2023-01-27 14:49:46 -03:00
Vitor Pamplona
8a9b14fe5f Cleans up spaces on relay addresses 2023-01-27 14:15:14 -03:00
Vitor Pamplona
68c8c8cd98 v0.11.1 2023-01-27 13:44:23 -03:00
Vitor Pamplona
e751c206b2 Keeping image cache in the disk only 2023-01-27 13:42:44 -03:00
Vitor Pamplona
74df3333ef Fixes replying information 2023-01-27 13:41:54 -03:00
Vitor Pamplona
b5ee783107 Accepts more Like/Dislike codes 2023-01-27 13:41:42 -03:00
Vitor Pamplona
5452196f42 New root search for the thread view 2023-01-27 13:16:45 -03:00
Vitor Pamplona
b7f13f6474 Merge remote-tracking branch 'origin/main' 2023-01-27 12:48:49 -03:00
Vitor Pamplona
b4fb623a01 Adding verification tag in the thread view and like/boost notification cards 2023-01-27 12:35:41 -03:00
Vitor Pamplona
88a82b3fbc Requires users to accept terms of use as per Google Play requirement. 2023-01-27 11:53:21 -03:00
Vitor Pamplona
3dc09cce11 Updated Privacy Policy to define objectionable content 2023-01-27 09:25:14 -05:00
Vitor Pamplona
7399afa648 v0.11.0 2023-01-26 22:14:44 -03:00
Vitor Pamplona
4e74ca6ed0 Removing BRB due to the crashes. 2023-01-26 22:14:12 -03:00
Vitor Pamplona
e20669ab3d Support for Notification Bubbles and Verified Follows 2023-01-26 22:09:56 -03:00
Vitor Pamplona
93033295be Marks following users with a Green verified mark 2023-01-26 13:16:57 -03:00
Vitor Pamplona
d8ef083086 v0.10.7 2023-01-26 00:11:05 -03:00
Vitor Pamplona
686dc954d9 v0.10.6 2023-01-25 23:08:27 -03:00
Vitor Pamplona
0ad8176a7c Fixes lost font size/style in the latest RTL change. 2023-01-25 23:05:32 -03:00
Vitor Pamplona
47f89232c1 Fixes relay list loading problems 2023-01-25 22:33:50 -03:00
Vitor Pamplona
5c9b736591 Migrates dependencies to new versions. App now requires Android Electric Eel 2023-01-25 22:33:37 -03:00
Vitor Pamplona
875a518a05 Merge remote-tracking branch 'origin/main' 2023-01-25 19:28:49 -03:00
Vitor Pamplona
22311f39e0 Updating lifecycle to 2.6.0 2023-01-25 19:27:09 -03:00
Vitor Pamplona
f6ad33b76a StateWithLifecycle seems to be the wrong option here. 2023-01-25 19:26:48 -03:00
Vitor Pamplona
05267c598f Merge pull request #38 from KotlinGeekDev/rtl-fix
RTL Support
2023-01-25 17:25:27 -05:00
Vitor Pamplona
f988d3e488 Merge pull request #37 from KotlinGeekDev/fix-note-navigation
Fix note navigation behaviour.
2023-01-25 17:24:38 -05:00
Vitor Pamplona
bd7a4a8372 Concurrent modification exception bugfix 2023-01-25 19:09:18 -03:00
Vitor Pamplona
a9788cfa63 Separating Note observers into different types to avoid full screen refreshes. 2023-01-25 18:54:20 -03:00
KotlinGeekDev
74b0dd3b44 Support for RTL in both TextFields and TextViews. 2023-01-25 18:59:52 +01:00
KotlinGeekDev
88ccb63a80 Fix note navigation behaviour. 2023-01-25 17:39:19 +01:00
Vitor Pamplona
542bd485d0 Fixes notification autoupdating 2023-01-25 13:29:44 -03:00
Vitor Pamplona
4c15420b05 Fixes multiple likes 2023-01-25 13:24:56 -03:00
Vitor Pamplona
e4dfb2b974 Fixes https://github.com/vitorpamplona/amethyst/issues/30 2023-01-25 13:01:44 -03:00
Vitor Pamplona
71c847b478 v0.10.5 2023-01-25 12:02:13 -03:00
Vitor Pamplona
5f1ce602ea Smaller buttons in the Profile and Channel to support narrower screens. 2023-01-25 12:01:47 -03:00
Vitor Pamplona
380181fd05 More obvious nsec, npub buttons. 2023-01-25 11:32:09 -03:00
Vitor Pamplona
857a31faf0 Blocks double retweets. 2023-01-25 11:00:17 -03:00
Vitor Pamplona
12e5ebeb84 Upgrading Cameri's relay 2023-01-25 10:53:18 -03:00
Vitor Pamplona
965cdd9304 Fix boosted posts not showing up in the Main Follows feed 2023-01-25 10:46:14 -03:00
Vitor Pamplona
e7b6ca1bb2 Fixes likes not showing up in Notifications 2023-01-25 10:40:45 -03:00
Vitor Pamplona
30ab477d7d Fixes channels as hidden users. 2023-01-25 10:40:28 -03:00
Vitor Pamplona
68bb6b3479 Bugfix on updating ln address 2023-01-25 00:07:57 -03:00
Vitor Pamplona
ebe9690f1b 0.10.4 2023-01-24 23:28:43 -03:00
Vitor Pamplona
aa8993eb56 Splits home screen between Follows and Follows+ Replies 2023-01-24 23:28:34 -03:00
Vitor Pamplona
ca9f94eaac Logging failures to reduce the amount of network calls, 2023-01-24 23:09:03 -03:00
Vitor Pamplona
f931e8dfa9 Fixing order of relays when using default ones 2023-01-24 23:08:44 -03:00
Vitor Pamplona
b32c454303 Keeping the feed up to date if you are looking at the top 2023-01-24 22:53:15 -03:00
Vitor Pamplona
e9eb7de24a NIP 19 Support 2023-01-24 21:53:22 -03:00
Vitor Pamplona
654deb5e23 Removes the need to send the scroll to the bottom in chatrooms 2023-01-24 19:52:26 -03:00
Vitor Pamplona
cae9502d11 v0.10.3 2023-01-24 16:59:31 -03:00
Vitor Pamplona
687428abc1 Support for Blocking and Reporting Users/Posts 2023-01-24 16:59:21 -03:00
Vitor Pamplona
45ea408877 Updating assets for fastlane 2023-01-23 21:18:16 -03:00
Vitor Pamplona
c863b6c5e5 Merge pull request #5 from IzzySoft/fastlane
initial fastlane structure
2023-01-23 19:10:29 -05:00
Vitor Pamplona
34dd009246 v0.10.2 2023-01-23 20:58:36 -03:00
Vitor Pamplona
e064741148 Moving coroutines from Main to Default, which is more ideal for memory intensive tasks 2023-01-23 20:54:56 -03:00
Vitor Pamplona
b6c25cfa46 Not updating observers if the database doesn't change. 2023-01-23 20:54:10 -03:00
Vitor Pamplona
114f064bbf Fixes a bug in updating a list of relays after a bug from nostr-postr 2023-01-23 20:53:37 -03:00
Vitor Pamplona
aeb78b293e No more black profile pictures while loading 2023-01-23 20:32:10 -03:00
Vitor Pamplona
2b4a1a3966 Updating dependencies 2023-01-23 20:30:40 -03:00
Vitor Pamplona
582f8e39c0 Releasing a new version to make sure versionCode was updated. 2023-01-23 16:30:18 -03:00
Vitor Pamplona
a6c3c5c606 Delete old logs 2023-01-23 15:16:26 -03:00
Vitor Pamplona
b641678222 migrating v0l to snort 2023-01-23 15:02:36 -03:00
Vitor Pamplona
33fc47f1aa v.0.10.0 2023-01-23 14:30:33 -03:00
Vitor Pamplona
8d3d543318 Avoids duplicating urls in relay list add. 2023-01-23 14:11:13 -03:00
Vitor Pamplona
a47aaab83c Relay Management (View/Edit) 2023-01-23 13:58:06 -03:00
Vitor Pamplona
c33f7f615f Avoiding recurring inner note rendering. 2023-01-22 19:07:32 -03:00
Vitor Pamplona
0c2fd3adc0 Doesn't try to link phone numbers smaller than 6 chars 2023-01-22 19:07:06 -03:00
Vitor Pamplona
a366809319 Search by partial Hex or full npub/note activated. 2023-01-22 18:35:10 -03:00
Vitor Pamplona
7baef64af9 Single line about me texts in the follow list 2023-01-22 18:34:41 -03:00
Vitor Pamplona
9c04cb0e30 putting synchronized lists into a synchronized method 2023-01-22 18:34:21 -03:00
Vitor Pamplona
d13979b4b5 Marking Chat groups as such in the UI 2023-01-22 18:33:38 -03:00
Vitor Pamplona
9f15b87d57 Allowing unloaded Notes to go into the feed and be loaded later 2023-01-22 18:33:22 -03:00
Vitor Pamplona
59305c9b15 Using full pubkeys in User requests 2023-01-22 18:32:22 -03:00
Vitor Pamplona
53eeba210a Start of the work to have following since <date> 2023-01-22 18:31:23 -03:00
Vitor Pamplona
38c17f9024 Unnecessary Regex compile 2023-01-22 18:29:59 -03:00
Vitor Pamplona
0c96d0a3fd 0.9.6 2023-01-21 21:14:19 -03:00
Vitor Pamplona
7be2d08db1 View Count 2023-01-21 21:14:10 -03:00
Vitor Pamplona
0a02c66c5a Merge pull request #7 from erjanmx/fix-readme-typo
Fix readme typo
2023-01-21 14:01:12 -05:00
Erjan Kalybek
261eecbcc3 Fix readme typo 2023-01-21 20:00:19 +01:00
Vitor Pamplona
f17936f029 Version 0.9.5 2023-01-21 13:53:48 -03:00
Vitor Pamplona
d64766516a Removing unnecessary observables 2023-01-21 13:53:38 -03:00
Vitor Pamplona
d685219ed9 Remove blinking 2023-01-21 13:53:12 -03:00
Vitor Pamplona
bb9217466a Hard Refresh when the button to refresh is pressed. 2023-01-21 13:37:57 -03:00
Vitor Pamplona
80f58875bb Uses cached images instead of reloading them by default 2023-01-21 13:32:22 -03:00
Vitor Pamplona
ffe58f3d41 Fixing click home button to go to the top of the home feed. 2023-01-21 13:07:47 -03:00
Vitor Pamplona
733801fddf fixing alignment of the reaction buttons 2023-01-21 12:48:14 -03:00
Vitor Pamplona
5cb6bca224 Fixing concurrent modification exception in reactions and boosts 2023-01-21 12:41:05 -03:00
Vitor Pamplona
790dd778bb Fixing quote of the root image in Messages 2023-01-21 12:40:49 -03:00
Vitor Pamplona
a692ad47d6 Spam filter in new private messages 2023-01-21 12:31:23 -03:00
Vitor Pamplona
46a8f81387 Adding more logo versions and store banners 2023-01-21 10:58:54 -03:00
Vitor Pamplona
acb07352b7 Updated Screenshots 2023-01-21 10:56:24 -03:00
Vitor Pamplona
5268da117c Updating Privacy policy
Adding Data deletion path and contact info
2023-01-21 08:54:46 -05:00
Vitor Pamplona
b6c1273ecd v0.9.4 2023-01-21 00:55:25 -03:00
Vitor Pamplona
07f9c6e8c3 keeping other classes to make sure deserialization works. 2023-01-21 00:54:39 -03:00
Vitor Pamplona
ea53a15105 Adding a Basic Privacy Policy 2023-01-20 23:56:54 -03:00
Vitor Pamplona
716a150fc3 Changing my mind... keeping the dot in time ago 2023-01-20 23:33:43 -03:00
Vitor Pamplona
df3ae43ce7 Adjusting the message screen to correctly display the time aligned to the right. 2023-01-20 23:31:44 -03:00
Vitor Pamplona
09bbb59c61 v0.9.3 2023-01-20 23:05:56 -03:00
Vitor Pamplona
0f731117d4 Seems to solve constant crashing when reconnecting after a failure related to this issue: https://github.com/square/okhttp/issues/7381 2023-01-20 23:01:53 -03:00
Vitor Pamplona
115c1ba082 Edit Profile 2023-01-20 23:00:30 -03:00
Vitor Pamplona
135b24df6c Fixing NPE when socket disconnects after the initial check if == null 2023-01-20 18:57:50 -03:00
Vitor Pamplona
207f03bb06 Merge pull request #6 from KotlinGeekDev/fix-navigation-issues
Fix navigation.
2023-01-20 16:48:25 -05:00
KotlinGeekDev
e78c7aba85 Fix navigation. 2023-01-20 22:31:19 +01:00
Izzy
de6c594d5a initial fastlane structure 2023-01-20 21:58:28 +01:00
Vitor Pamplona
41b82ec749 Merge pull request #3 from KotlinGeekDev/reduce-apk-size
Make changes to reduce the release APK/app bundle size.
2023-01-20 14:44:58 -05:00
KotlinGeekDev
b915956f57 Make changes to reduce the release APK/app bundle size. 2023-01-20 20:26:46 +01:00
Vitor Pamplona
144b0a7512 v0.9.2:
- Fixes bug when tagging new users on reply
- Fixes bug on likes not being shown.
- Fixes alignment of the time in the home feed.
2023-01-19 21:48:15 -05:00
Vitor Pamplona
359eb0b000 Downloading just the last metadata for users being displayed in the screen. 2023-01-19 21:40:24 -05:00
Vitor Pamplona
e47476129f BugFix for the position of time in the feed. 2023-01-19 21:39:43 -05:00
Vitor Pamplona
0017845de2 Fixing the bug that wasn't showing likes 2023-01-19 21:35:57 -05:00
Vitor Pamplona
ab0bab0c16 Solving User tagging bug 2023-01-19 21:35:36 -05:00
Vitor Pamplona
b9b1da1f04 v0.9.1 2023-01-19 18:26:12 -05:00
Vitor Pamplona
0e22a25d3d Removing double call refresh 2023-01-19 18:14:56 -05:00
Vitor Pamplona
8b61dc09d0 Showing likes for Public Chat events. 2023-01-19 18:14:42 -05:00
Vitor Pamplona
e1fa46290e Correctly shows Chat notifications in the Notifications Tab. 2023-01-19 17:58:35 -05:00
Vitor Pamplona
ec5f510264 Shows replies in chat 2023-01-19 17:58:01 -05:00
Vitor Pamplona
2d46b17493 Block like breaks in the User Information block of the layout. 2023-01-19 17:57:43 -05:00
Vitor Pamplona
99f478d891 Fixing large usernames breaking the layout 2023-01-19 17:56:44 -05:00
Vitor Pamplona
451137e8fa handling co-routines with viewModel Scope 2023-01-19 17:56:22 -05:00
Vitor Pamplona
fc88c2867d Adding replying information for notes in a channel 2023-01-19 17:55:57 -05:00
Vitor Pamplona
98704bc43d remove transformations 2023-01-19 17:55:37 -05:00
Vitor Pamplona
c5c8ffc70f Avoid using trasnformations. They won't work in this application (base object never changes) 2023-01-19 17:55:25 -05:00
Vitor Pamplona
94a228d78a Simplifying the name of the "Add Image from Gallery" button 2023-01-19 17:54:30 -05:00
Vitor Pamplona
625bbaf35c Marking relays with failure as not connected 2023-01-19 17:54:07 -05:00
Vitor Pamplona
dd35e01f8a Fixing null pointer exceptions 2023-01-19 17:53:51 -05:00
Vitor Pamplona
c1d46dcc2f Using short-term co-routines 2023-01-19 17:52:32 -05:00
Vitor Pamplona
85c66279b2 Removing unnecessary code 2023-01-19 17:51:35 -05:00
Vitor Pamplona
a18c5b975f Solving some of the Out of Memory errors. 2023-01-19 17:50:29 -05:00
Vitor Pamplona
4224ebe429 Version 0.9 2023-01-19 08:44:41 -05:00
Vitor Pamplona
57636607d2 Version 0.9 2023-01-19 08:42:36 -05:00
Vitor Pamplona
8771584deb Moving away from handlers and into coroutines. 2023-01-19 08:41:48 -05:00
Vitor Pamplona
9d52180550 Join/Leave Channels, Channel search. 2023-01-19 08:03:01 -05:00
Vitor Pamplona
75219f0f2d Add Channel 2023-01-18 22:00:32 -05:00
Vitor Pamplona
0e3b007730 Fixing Public chat's event order. 2023-01-18 18:59:18 -05:00
Vitor Pamplona
ce4e3e6589 Fixing #1, unable to see bio in the Profile Screen 2023-01-18 17:22:01 -05:00
Vitor Pamplona
f1dd5669a1 v0.8.2 2023-01-18 17:13:08 -05:00
Vitor Pamplona
4271ae77d2 Batch updates to enhance performance. 2023-01-18 17:12:43 -05:00
Vitor Pamplona
3846ae2af5 Search Bar 2023-01-18 16:50:03 -05:00
Vitor Pamplona
a4885e643e Add threading for the LazyColumn refresh. 2023-01-18 15:55:35 -05:00
Vitor Pamplona
3441f7987b fix for copying the encrypted part of the message instead of the plain text. 2023-01-18 15:55:10 -05:00
Vitor Pamplona
9792733f72 New Observer/Observable performance improvements. 2023-01-18 15:30:39 -05:00
Vitor Pamplona
bf8a0f0e76 Additional Performance Improvements 2023-01-18 14:24:04 -05:00
Vitor Pamplona
381711b72e Version 0.8.1 2023-01-18 12:21:29 -05:00
Vitor Pamplona
ff529a920e Moving URL Preview fetcher to thread + running over a given feed list. 2023-01-18 12:21:00 -05:00
Vitor Pamplona
7d18f36e3e Improving performance with more precise filters. 2023-01-18 11:44:31 -05:00
Vitor Pamplona
f2b913f7c8 Updating dependencies 2023-01-18 09:18:34 -05:00
Vitor Pamplona
d171552efd Clickable Phone and Emails 2023-01-18 09:18:26 -05:00
Vitor Pamplona
cae674ee99 v0.8 2023-01-18 08:37:57 -05:00
Vitor Pamplona
3ee39887a8 Tags users during message compose. 2023-01-18 08:36:42 -05:00
Vitor Pamplona
8f45293be9 New design (Visual Identity and Logo) by Lula Rocha 2023-01-17 18:25:38 -05:00
Vitor Pamplona
d0b6e96c2f Possibility to remove mentions from replies. 2023-01-17 11:16:50 -05:00
Vitor Pamplona
0789752112 Putting user tags in the reply Information box 2023-01-17 10:43:18 -05:00
Vitor Pamplona
21b16984bb onLong click in the Image now copies the URL 2023-01-17 10:18:45 -05:00
Vitor Pamplona
ee9422077e Improvement to chat bubbles layout 2023-01-17 10:18:30 -05:00
Vitor Pamplona
0667a822f1 Makes lists either immutable or puts them into a synchronized block for thread safety 2023-01-17 08:42:00 -05:00
Vitor Pamplona
2037b35af6 Adding new tasks. 2023-01-16 17:30:06 -05:00
Vitor Pamplona
01425dd42e v0.7 2023-01-16 16:42:46 -05:00
Vitor Pamplona
5667bd5140 Adding support for Anigma's chat 2023-01-16 16:34:49 -05:00
Vitor Pamplona
ed0ca1ab11 Case insensitive LNURL match. 2023-01-16 13:00:20 -05:00
Vitor Pamplona
f5572f7ea5 Support for Gifs and SVGs 2023-01-16 12:57:23 -05:00
Vitor Pamplona
f9b86585be Support for User Tags on posts. 2023-01-16 10:51:10 -05:00
Vitor Pamplona
f2b831a119 Version 0.6 2023-01-15 22:08:17 -05:00
Vitor Pamplona
6605c92626 BugFix: Unparseable LN invoice 2023-01-15 21:58:17 -05:00
Vitor Pamplona
4543a68615 Support for User Profiles 2023-01-15 21:52:59 -05:00
Vitor Pamplona
5791511bd6 Fixes bug in not showing tags with index 0 2023-01-15 16:14:32 -05:00
Vitor Pamplona
933731215c Imports User's banners from Damus 2023-01-15 16:13:49 -05:00
Vitor Pamplona
4026ae03e7 Fixes profile images to square sizes 2023-01-15 11:52:45 -05:00
Vitor Pamplona
0539861008 Adds error handling for invalid keys on login 2023-01-15 11:50:01 -05:00
Vitor Pamplona
ac437ab7ba hides private key on login 2023-01-15 11:25:53 -05:00
Vitor Pamplona
0116bbe01b Migrates to Compose 1.3.3 2023-01-15 11:25:45 -05:00
Vitor Pamplona
adca60f613 Removing unsupported click on messages 2023-01-15 11:04:05 -05:00
Vitor Pamplona
2462b957a3 v0.5 2023-01-14 18:03:47 -05:00
Vitor Pamplona
a8dd13e13b Supports Private Messages 2023-01-14 18:02:28 -05:00
Vitor Pamplona
aa11bf212a Private message support 2023-01-14 17:56:18 -05:00
Vitor Pamplona
f580fdd216 Storing and Counting followers of the main account. 2023-01-14 10:16:36 -05:00
Vitor Pamplona
d130a43358 Sending confirmation events back to the Repository. 2023-01-13 21:35:28 -05:00
Vitor Pamplona
353046b451 Better info about relays in the Top Nav bar 2023-01-13 21:35:12 -05:00
Vitor Pamplona
5859ae2c52 Removing autoplay due to audio (need to figure out how to mute first) 2023-01-13 21:34:31 -05:00
Vitor Pamplona
66cfa9201c Simplifies the first call to Nostr relays 2023-01-13 20:17:24 -05:00
Vitor Pamplona
380c2e67cc Adds a click to zoom image. 2023-01-13 20:16:57 -05:00
Vitor Pamplona
0001ae441f Blocks double likes. 2023-01-13 17:28:23 -05:00
Vitor Pamplona
594795fc16 Version v0.4 2023-01-13 12:30:40 -05:00
Vitor Pamplona
9b95e1de51 Lightning invoice card 2023-01-13 12:30:13 -05:00
Vitor Pamplona
962bd9eb2d Dropdown menu to copy information from the note. 2023-01-13 10:20:54 -05:00
Vitor Pamplona
9d4f4c67f1 Better timeAgo formatting 2023-01-13 09:51:14 -05:00
Vitor Pamplona
c1d6d965cd Improves Note's padding and click area. 2023-01-13 09:39:45 -05:00
Vitor Pamplona
d676d57614 Improving documentation for the Note Class 2023-01-13 09:39:14 -05:00
Vitor Pamplona
582b55c6be Caching URL Infos 2023-01-13 09:39:03 -05:00
Vitor Pamplona
fd1f8663e5 Version 0.3 2023-01-12 22:40:45 -05:00
Vitor Pamplona
69fdc4fcd4 Adding video playback to the timeline 2023-01-12 22:40:39 -05:00
Vitor Pamplona
815f044f77 Better permission handling for images. 2023-01-12 22:36:09 -05:00
Vitor Pamplona
a336118d0d Removing unnecessary logs 2023-01-12 22:35:51 -05:00
Vitor Pamplona
bf827fd1f4 Image uploading and Image/URL previews on new posts. 2023-01-12 21:14:44 -05:00
215 changed files with 15831 additions and 2192 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store
/build
/captures

13
PRIVACY.md Normal file
View File

@@ -0,0 +1,13 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com

View File

@@ -11,13 +11,24 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image Preview
- [x] Image Preview (gifs, svgs)
- [x] Url Preview
- [x] View Threads
- [ ] Private Messages
- [ ] Communities
- [ ] Profile Edit
- [ ] Relay Edit
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] Public Chats (NIP-28)
- [x] Notification Bubbles
- [x] Reporting and Hide User capability
- [x] Profile Edit
- [x] Relay Edit (separate relay sets for home, dms, public chats, global)
- [x] Dropdown to Link Users when writing
- [x] Lightning Tips
- [ ] Zaps
- [ ] Infinity Scroll
- [ ] Identity Verification (NIP-05)
- [ ] Event Deletion (NIP-09)
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
# Development Overview
@@ -114,4 +125,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 2
versionName "0.2"
versionCode 59
versionName "0.17.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,8 +22,14 @@ android {
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
}
compileOptions {
@@ -37,7 +43,7 @@ android {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
kotlinCompilerExtensionVersion '1.4.0'
}
packagingOptions {
resources {
@@ -48,28 +54,30 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.3.1'
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha03'
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha03'
// Input
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation 'androidx.compose.material:material:1.4.0-alpha05'
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -78,19 +86,58 @@ dependencies {
exclude module: 'guava-testlib'
}
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:0.28.0"
// Robohash for Avatars
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha03'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
// view gifs
implementation "io.coil-kt:coil-gif:2.2.2"
// view svgs
implementation("io.coil-kt:coil-svg:2.2.2")
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
// tabs for user profiles
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
// Local Barcode Scanning model
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'

View File

@@ -18,4 +18,20 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
# For the Secp256k1 library
-keepattributes InnerClasses
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }

View File

@@ -1,24 +0,0 @@
package com.vitorpamplona.amethyst
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
}
}

View File

@@ -0,0 +1,100 @@
package com.vitorpamplona.amethyst
import androidx.compose.ui.graphics.Color
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 org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class EUrlUserTagTransformationTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
}
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
}
}

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
</resources>

View File

@@ -3,33 +3,52 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst_logo"
android:icon="@drawable/amethyst"
android:label="@string/app_name"
android:roundIcon="@drawable/amethyst_logo"
android:enableOnBackInvokedCallback="true"
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
tools:targetApi="31">
android:largeHeap="true"
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostr" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
</application>
</manifest>

View File

@@ -4,9 +4,9 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class KeyStorage {
class EncryptedStorage {
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
fun preferences(context: Context): EncryptedSharedPreferences {
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
val preferencesName = "secret_keeper"

View File

@@ -0,0 +1,90 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
val gson = GsonBuilder().create()
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
remove("relays")
remove("dontTranslateFrom")
remove("translateTo")
remove("zapAmounts")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) }
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", null) ?: setOf()
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
val zapAmountChoices = gson.fromJson(
getString("zapAmounts", "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
translateTo,
zapAmountChoices
)
} else {
return null
}
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
putLong("last_read_route_${route}", timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
return getLong("last_read_route_${route}", 0)
}
}
}

View File

@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.model.Note
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
invalidateData()
}
}
}
fun load(route: String, context: Context): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences(context).loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)

View File

@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.util.UUID
import name.neuhalfen.projects.android.robohash.RoboHash
object RoboHashCache {
lateinit var robots: RoboHash
lateinit var defaultAvatar: ImageBitmap
@Synchronized
fun get(context: Context, hash: String): ImageBitmap {
if (!this::robots.isInitialized) {
robots = RoboHash(context)
//robots.useCache(LruCache(100));
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
}
return defaultAvatar
}
}

View File

@@ -0,0 +1,80 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.relays.Client
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
this.account = account
start()
}
fun start() {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}
fun pause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrGlobalDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
Client.disconnect()
}
fun cleanUp() {
LocalCache.cleanObservers()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
//LocalCache.pruneNonFollows(it)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
object VideoCache {
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
lateinit var exoDatabaseProvider: StandaloneDatabaseProvider
lateinit var simpleCache: SimpleCache
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
fun get(context: Context): CacheDataSource.Factory {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
simpleCache = SimpleCache(
context.cacheDir,
leastRecentlyUsedCacheEvictor,
exoDatabaseProvider
)
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
return cacheDataSourceFactory
}
}

View File

@@ -0,0 +1,157 @@
package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
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.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
if (lnaddress.toLowerCase().startsWith("lnurl")) {
return try {
String(Bech32.decodeBytes(lnaddress, false).second)
} catch (e: Exception) {
null
}
}
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) {
val url = assembleUrl(lnaddress)
if (url == null) {
onError("Could not assemble LNUrl from Lightning Address \"${lnaddress}\". Check the user's setup")
return
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve User's LNURL address from ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
e.printStackTrace()
}
})
}
}
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().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from ${lnCallback}. Message ${e.message}")
e.printStackTrace()
}
})
}
}
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
fetchLightningAddressJson(lnaddress,
onSuccess = {
onSuccess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
},
onError = onError
)
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(lnaddress,
onSuccess = {
val lnurlp = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError("Callback URL not found in the User's lightning address server configuration")
}
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
callback?.let { callback ->
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
onSuccess = {
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
null
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
}
},
onError = onError
)
}
},
onError = onError
)
}
}

View File

@@ -0,0 +1,157 @@
package com.vitorpamplona.amethyst.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
/** based on litecoinj */
object LnInvoiceUtil {
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
/** The Bech32 character set for encoding. */
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
/** The Bech32 character set for decoding. */
private val CHARSET_REV = byteArrayOf(
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1
)
/** Find the polynomial with value coefficients mod the generator as 30-bit. */
private fun polymod(values: ByteArray): Int {
var c = 1
for (v_i in values) {
val c0 = c ushr 25 and 0xff
c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff)
if (c0 and 1 != 0) c = c xor 0x3b6a57b2
if (c0 and 2 != 0) c = c xor 0x26508e6d
if (c0 and 4 != 0) c = c xor 0x1ea119fa
if (c0 and 8 != 0) c = c xor 0x3d4233dd
if (c0 and 16 != 0) c = c xor 0x2a1462b3
}
return c
}
/** Expand a HRP for use in checksum computation. */
private fun expandHrp(hrp: String): ByteArray {
val hrpLength = hrp.length
val ret = ByteArray(hrpLength * 2 + 1)
for (i in 0 until hrpLength) {
val c = hrp[i].code and 0x7f // Limit to standard 7-bit ASCII
ret[i] = (c ushr 5 and 0x07).toByte()
ret[i + hrpLength + 1] = (c and 0x1f).toByte()
}
ret[hrpLength] = 0
return ret
}
/** Verify a checksum. */
private fun verifyChecksum(hrp: String, values: ByteArray): Boolean {
val hrpExpanded: ByteArray = expandHrp(hrp)
val combined = ByteArray(hrpExpanded.size + values.size)
System.arraycopy(hrpExpanded, 0, combined, 0, hrpExpanded.size)
System.arraycopy(values, 0, combined, hrpExpanded.size, values.size)
return polymod(combined) == 1
}
class AddressFormatException(message: String): Exception(message) {
}
fun decodeUnlimitedLength(invoice: String): Boolean {
var lower = false
var upper = false
for (i in 0 until invoice.length) {
val c = invoice[i]
if (c.code < 33 || c.code > 126) throw AddressFormatException("Invalid character: $c, pos: $i")
if (c in 'a'..'z') {
if (upper) throw AddressFormatException("Invalid character: $c, pos: $i")
lower = true
}
if (c in 'A'..'Z') {
if (lower) throw AddressFormatException("Invalid character: $c, pos: $i")
upper = true
}
}
val pos = invoice.lastIndexOf('1')
if (pos < 1) throw AddressFormatException("Missing human-readable part")
val dataPartLength = invoice.length - 1 - pos
if (dataPartLength < 6) throw AddressFormatException("Data part too short: $dataPartLength")
val values = ByteArray(dataPartLength)
for (i in 0 until dataPartLength) {
val c = invoice[i + pos + 1]
if (CHARSET_REV.get(c.code).toInt() == -1) throw AddressFormatException("Invalid character: " + c + ", pos: " + (i + pos + 1))
values[i] = CHARSET_REV.get(c.code)
}
val hrp = invoice.substring(0, pos).lowercase(Locale.ROOT)
if (!verifyChecksum(hrp, values)) throw AddressFormatException("Invalid Checksum")
return true
}
/**
* Parses invoice amount according to
* https://github.com/lightningnetwork/lightning-rfc/blob/master/11-payment-encoding.md#human-readable-part
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
throw IllegalArgumentException("Cannot decode invoice", e)
}
val matcher = invoicePattern.matcher(invoice)
require(matcher.matches()) { "Failed to match HRP pattern" }
val amountGroup = matcher.group("amount")
val multiplierGroup = matcher.group("multiplier")
if (amountGroup == null) {
return BigDecimal.ZERO
}
val amount = BigDecimal(amountGroup)
if (multiplierGroup == null) {
return amount
}
require(!(multiplierGroup == "p" && amountGroup[amountGroup.length - 1] != '0')) { "sub-millisatoshi amount" }
return amount.multiply(multiplier(multiplierGroup))
}
fun getAmountInSats(invoice: String): BigDecimal {
return getAmount(invoice).multiply(BigDecimal(100000000))
}
private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier.toLowerCase()) {
"m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001")
"p" -> BigDecimal("0.000000000001")
else -> throw IllegalArgumentException("Invalid multiplier: $multiplier")
}
}
/**
* Finds LN invoice in the provided input string and returns it.
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
* It will only return the first invoice found in the input.
*
* @return the invoice if it was found. null for null input or if no invoice is found
*/
fun findInvoice(input: String?): String? {
if (input == null) {
return null
}
val matcher = invoicePattern.matcher(input)
return if (matcher.find()) {
matcher.group()
} else null
}
}

View File

@@ -1,37 +1,197 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
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.LnZapRequestEvent
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.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
class Account(val loggedIn: Persona) {
var seeReplies: Boolean = true
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L)
) {
var transientHiddenUsers: Set<String> = setOf()
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
fun reactTo(note: Note) {
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
note.event?.let {
val event = ReactionEvent.create(it, loggedIn.privKey!!)
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows,
relays,
loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event)
LocalCache.consume(event)
}
fun sendNewUserMetadata(toString: String) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
Client.send(event)
LocalCache.consume(event)
}
}
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "+")) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
if (!isWriteable()) return null
if (note.hasZapped(userProfile())) {
// has already liked this note
return null
}
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
return null
}
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user.pubkeyHex)
}
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
// has already liked this note
return
}
note.event?.let {
val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun report(user: User, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (user.hasReport(userProfile(), type)) {
// has already reported this note
return
}
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
fun boost(note: Note) {
if (!isWriteable()) return
if (note.hasBoosted(userProfile())) {
// has already bosted in the past 5mins
return
}
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
@@ -39,39 +199,303 @@ class Account(val loggedIn: Persona) {
}
}
fun sendPost(message: String, replyingTo: Note?) {
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
}
}
fun follow(user: User) {
if (!isWriteable()) return
val replyToEvent = replyingTo?.event
if (replyToEvent is TextNoteEvent) {
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex())
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesTo,
mentions = mentions,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = null,
mentions = null,
privateKey = loggedIn.privKey!!
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
relays,
loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
Client.send(event)
LocalCache.consume(event)
}
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = ChannelMessageEvent.create(
message = message,
channel = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendCreateNewChannel(name: String, about: String, picture: String) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
name, about, picture
)
val event = ChannelCreateEvent.create(
channelInfo = metadata,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
invalidateData(live)
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
invalidateData(live)
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData(live)
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex
invalidateData(live)
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
invalidateData(live)
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
val metadata = ChannelCreateEvent.ChannelData(
name, about, picture
)
val event = ChannelMetadataEvent.create(
newChannelInfo = metadata,
originalChannelIdHex = channel.idHex,
privateKey = loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
}
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey
if (note.author == userProfile() && recepientPK != null)
pubkeyToUse = recepientPK
return try {
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
val retVal = Utils.decrypt(event.content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} else {
event?.content
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
invalidateData(liveLanguages)
}
fun updateTranslateTo(languageCode: String) {
translateTo = languageCode
invalidateData(liveLanguages)
}
fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
}?.toTypedArray()
}
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
fun reconnectIfRelaysHaveChanged() {
val newRelaySet = activeRelays() ?: convertLocalRelays()
if (!Client.isSameRelaySetConfig(newRelaySet)) {
Client.disconnect()
Client.connect(newRelaySet)
RelayPool.requestAndWatch()
}
}
init {
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged()
}
}
LocalCache.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
LocalCache.spamMessages.snapshot().values.forEach {
if (it !in hiddenUsers) {
val userToBlock = LocalCache.getOrCreateUser(it)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
transientHiddenUsers = transientHiddenUsers + it
}
}
}
}
}
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
private fun refreshObservers() {
live.refresh()
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
live.refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun isHidden(user: User) = user in hiddenUsers()
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& user.reportAuthorsBy( userProfile().follows ).size < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& note.reportAuthorsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is RepostEvent
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
} else {
emptyList()
}
return (note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports).toSet()
}
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
}
@@ -79,14 +503,6 @@ class AccountLiveData(private val account: Account): LiveData<AccountState>(Acco
fun refresh() {
postValue(AccountState(account))
}
override fun onActive() {
super.onActive()
}
override fun onInactive() {
super.onInactive()
}
}
class AccountState(val account: Account)

View File

@@ -0,0 +1,93 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.concurrent.ConcurrentHashMap
class Channel(val idHex: String) {
var creator: User? = null
var info = ChannelCreateEvent.ChannelData(null, null, null)
var updatedMetadataAt: Long = 0;
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.refresh()
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
private fun refreshObservers() {
live.refresh()
}
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.event?.createdAt }
.reversed()
.take(1000)
.toSet()
val toBeRemoved = notes.values.filter { it in important }.toSet()
toBeRemoved.forEach {
notes.remove(it.idHex)
}
return toBeRemoved
}
}
class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState(channel)) {
fun refresh() {
postValue(ChannelState(channel))
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
class ChannelState(val channel: Channel)

View File

@@ -1,8 +1,9 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import nostr.postr.Bech32
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
@@ -10,6 +11,19 @@ import nostr.postr.toHex
/** 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()
}
@@ -19,18 +33,51 @@ fun HexKey.toByteArray(): ByteArray {
}
fun HexKey.toDisplayHexKey(): String {
return this.toDisplayHex()
return this.toShortenHex()
}
fun decodePublicKey(key: String): ByteArray {
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else if (key.startsWith("note")) {
key.bechToBytes()
} else { //if (pattern.matcher(key).matches()) {
//} else {
Hex.decode(key)
}
}
data class DirtyKeyInfo(val type: String, val keyHex: String, 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)) {
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
} else if (key.startsWith("npub1", true)) {
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
} else if (key.startsWith("note1", true)) {
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}

View File

@@ -1,19 +1,41 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
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.relays.Relay
import fr.acinq.secp256k1.Hex
import java.io.ByteArrayInputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
@@ -28,17 +50,40 @@ object LocalCache {
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, String>(1000)
fun checkGetOrCreateUser(key: HexKey): User? {
return try {
val checkHex = Hex.decode(key) // Checks if this is a valid Hex
getOrCreateUser(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
null
}
}
@Synchronized
fun getOrCreateUser(pubkey: ByteArray): User {
val key = pubkey.toHexKey()
fun getOrCreateUser(key: HexKey): User {
return users[key] ?: run {
val answer = User(pubkey)
val answer = User(key)
users.put(key, answer)
answer
}
}
fun checkGetOrCreateNote(key: HexKey): Note? {
return try {
val checkHex = Hex.decode(key) // Checks if this is a valid Hex
getOrCreateNote(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create note: $key", e)
null
}
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
@@ -48,21 +93,44 @@ object LocalCache {
}
}
fun consume(event: MetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
fun checkGetOrCreateChannel(key: HexKey): Channel? {
return try {
val checkHex = Hex.decode(key) // Checks if this is a valid Hex
getOrCreateChannel(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
return channels[key] ?: run {
val answer = Channel(key)
channels.put(key, answer)
answer
}
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
if (event.createdAt > oldUser.updatedMetadataAt) {
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
metadataParser.readValue(
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
return
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.updateUserInfo(newUser, event)
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -73,29 +141,57 @@ object LocalCache {
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume(event: TextNoteEvent) {
@Synchronized
fun isRepeatedMessageSpam(event: Event): Boolean {
val idHex = event.id.toHexKey()
// if already processed, return
if (notes[idHex] != null) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if (event.content.length > 50 && ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null)) {
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, event.pubKey.toHexKey())
liveSpam.invalidateData()
}
return true
}
recentMessages.put(hash, idHex)
return false
}
fun consume(event: TextNoteEvent, relay: Relay? = null) {
if (isRepeatedMessageSpam(event)) return
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
// Counts the replies
@@ -106,38 +202,103 @@ object LocalCache {
refreshObservers()
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return event.replyTos.filter { it !in citations }
}
fun consume(event: RecommendRelayEvent) {
//Log.d("RR", event.toJson())
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows}")
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
user.updateFollows(
event.follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println(event.toJson())
println("UpdateFollows: " + event.toJson())
e.printStackTrace()
null
}
}.filterNotNull(),
}.filterNotNull().toSet(),
event.createdAt
)
// Saves relay list only if it's a user that is currently been seen
if (user.liveSet?.isInUse() == true) {
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
e.printStackTrace()
}
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
}
Log.d(
"CL",
"AAA ${user.toBestDisplayName()} ${event.follows.size}"
)
}
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
recipient.addMessage(author, note)
}
refreshObservers()
}
fun consume(event: PrivateDmEvent) {
//Log.d("PM", event.toJson())
}
fun consume(event: DeletionEvent) {
//Log.d("DEL", event.toJson())
}
@@ -148,23 +309,23 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }.toList()
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }.toMutableList()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
// Counts the replies
@@ -176,14 +337,14 @@ object LocalCache {
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }.toMutableList()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -191,30 +352,353 @@ object LocalCache {
// Adds notifications to users.
mentions.forEach {
it.taggedPosts.add(note)
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.taggedPosts?.add(note)
it.author?.addTaggedPost(note)
}
if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") {
if (
event.content == "" ||
event.content == "+" ||
event.content == "\u2764\uFE0F" || // red heart
event.content == "\uD83E\uDD19" || // call me hand
event.content == "\uD83D\uDC4D" // thumbs up
) {
// Counts the replies
repliesTo.forEach {
it.addReaction(note)
}
}
if (event.content == "!" // nostr_console hide.
|| event.content == "\u26A0\uFE0F" // Warning sign
) {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
}
}
fun consume(event: ReportEvent) {
val note = getOrCreateNote(event.id.toHex())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
if (repliesTo.isEmpty()) {
mentions.forEach {
it.addReport(note)
}
}
repliesTo.forEach {
it.addReport(note)
}
}
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
} else {
// older data, does nothing
}
}
fun consume(event: ChannelMetadataEvent) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
if (event.channel.isNullOrBlank()) return
// new event
val oldChannel = checkGetOrCreateChannel(event.channel) ?: return
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
}
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
if (isRepeatedMessageSpam(event)) return
val channel = checkGetOrCreateChannel(event.channel) ?: return
val note = getOrCreateNote(event.id.toHex())
channel.addNote(note)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers()
}
fun consume(event: ChannelHideMessageEvent) {
}
fun consume(event: ChannelMuteUserEvent) {
}
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
val zapRequest = event.containedPost?.id?.toHexKey()?.let { getOrCreateNote(it) }
if (zapRequest == null) {
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
//Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
mentions.forEach {
it.addZap(zapRequest, note)
}
}
fun consume(event: LnZapRequestEvent) {
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
mentions.forEach {
it.addZap(note, null)
}
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter {
(it.anyNameStartsWith(username))
|| it.pubkeyHex.startsWith(username, true)
|| it.pubkeyNpub().startsWith(username, true)
}
}
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && 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)
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
return channels.values.filter {
it.anyNameStartsWith(text)
|| it.idHex.startsWith(text, true)
|| it.idNote().startsWith(text, true)
}
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
}
users.forEach {
it.value.clearLive()
}
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach {
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
users[it]?.notes ?: emptySet()
}.flatten()
account.hiddenUsers.forEach {
users[it]?.clearNotes()
}
toBeRemoved.forEach {
notes.remove(it.idHex)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val liveSpam: LocalCacheLiveData = LocalCacheLiveData(this)
private fun refreshObservers() {
live.refresh()
live.invalidateData()
}
}
class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(LocalCacheState(cache)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}

View File

@@ -2,34 +2,67 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.math.BigDecimal
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import nostr.postr.events.Event
import nostr.postr.toNpub
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
val id = Hex.decode(idHex)
val idDisplayHex = id.toDisplayHex()
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: MutableList<Note>? = null
var replyTo: List<Note>? = null
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
// These fields are updated every time an event related to this note is received.
var replies = setOf<Note>()
private set
var reactions = setOf<Note>()
private set
var boosts = setOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
var relays = setOf<String>()
private set
var channel: Channel? = null
var lastReactionsDownloadTime: Long? = null
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
refreshObservers()
liveSet?.metadata?.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -37,41 +70,86 @@ class Note(val idHex: String) {
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
fun replyLevelSignature(): String {
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
return replyTo
.map { it.replyLevelSignature() }
.map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
}
fun replyLevel(): Int {
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
return 0
}
return replyTo.maxOf {
it.replyLevel()
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (replies.add(note))
refreshObservers()
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
}
}
fun addBoost(note: Note) {
if (boosts.add(note))
refreshObservers()
if (note !in boosts) {
boosts = boosts + note
liveSet?.boosts?.invalidateData()
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (reactions.add(note))
refreshObservers()
if (note !in reactions) {
reactions = reactions + note
liveSet?.reactions?.invalidateData()
}
}
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveSet?.relays?.invalidateData()
}
}
fun isZappedBy(user: User): Boolean {
// Zaps who the requester was the user
return zaps.any { it.key.author == user }
}
fun isReactedBy(user: User): Boolean {
@@ -82,15 +160,134 @@ class Note(val idHex: String) {
return boosts.any { it.author == user }
}
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
private fun refreshObservers() {
live.refresh()
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24*60*60
return reports.isNotEmpty() ||
(author?.reports?.values?.filter {
it.firstOrNull { ( it.event?.createdAt ?: 0 ) > dayAgo } != null
}?.isNotEmpty() ?: false)
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile
|| (userProfile in directlyCiteUsers())
|| (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|| (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
}
fun hasBoosted(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
liveSet = null
}
}
}
class NoteLiveSet(u: Note) {
// Observers line up here.
val metadata: NoteLiveData = NoteLiveData(u)
val reactions: NoteLiveData = NoteLiveData(u)
val boosts: NoteLiveData = NoteLiveData(u)
val replies: NoteLiveData = NoteLiveData(u)
val reports: NoteLiveData = NoteLiveData(u)
val relays: NoteLiveData = NoteLiveData(u)
val zaps: NoteLiveData = NoteLiveData(u)
fun isInUse(): Boolean {
return reactions.hasObservers()
|| boosts.hasObservers()
|| replies.hasObservers()
|| reports.hasObservers()
|| relays.hasObservers()
|| zaps.hasObservers()
}
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}
fun refresh() {
postValue(NoteState(note))
}

View File

@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)

View File

@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.model
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return 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)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)
searchRoot(it, testedNotes)
else
null
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
val (result, elapsed) = measureTimedValue {
val note = LocalCache.getOrCreateNote(noteId)
if (note.event != null) {
val thread = mutableSetOf<Note>()
val threadRoot = searchRoot(note, thread) ?: note
loadDown(threadRoot, thread)
thread.toSet()
} else {
setOf(note)
}
}
println("Model Refresh: Thread loaded in ${elapsed}")
return result
}
fun loadDown(note: Note, thread: MutableSet<Note>) {
if (note !in thread) {
thread.add(note)
note.replies.forEach {
loadDown(it, thread)
}
}
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
var cache = mapOf<String, UrlInfoItem>()
private set
var failures = mapOf<String, Throwable>()
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
callback?.onComplete(it)
return
}
failures[url]?.let {
callback?.onFailed(it)
return
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
}
}).fetchUrlPreview()
}
}
fun findUrlsInMessage(message: String): List<String> {
return message.split('\n').map { paragraph ->
paragraph.split(' ').filter { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}.flatten()
}
fun preloadPreviewsFor(note: Note) {
note.event?.content?.let {
findUrlsInMessage(it).forEach {
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
// Preload Images? Isn't this too heavy?
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
// Do nothing for now.
} else if (isValidURL(removedParamsFromUrl)) {
previewInfo(it)
} else {
previewInfo("https://${it}")
}
}
}
}
}

View File

@@ -1,71 +1,333 @@
package com.vitorpamplona.amethyst.model
import android.util.Patterns
import androidx.compose.ui.text.toLowerCase
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
import java.util.Collections
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.toNpub
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
val pubkeyDisplayHex = pubkey.toDisplayHex()
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
var info = UserMetadata()
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var latestContactList: ContactListEvent? = null
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
var follower: Number? = null
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
private set
var latestReportTime: Long = 0
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<User, Chatroom>()
private set
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
fun bestUsername(): String? {
return info.name?.ifBlank { null } ?: info.username?.ifBlank { null }
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
}
fun profilePicture(): String {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
follows.clear()
follows.addAll(newFollows)
fun follow(user: User, followedAt: Long) {
follows = follows + user
user.followers = user.followers + this
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
// No need for Listener yet
}
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
// No need for Listener yet
}
}
fun clearNotes() {
notes = setOf<Note>()
}
fun addReport(note: Note) {
val author = note.author ?: return
if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
} else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData()
}
val reportTime = note.event?.createdAt ?: 0
if (reportTime > latestReportTime) {
latestReportTime = reportTime
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
}
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
}
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
@Synchronized
fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatroom
}
}
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
liveSet?.messages?.invalidateData()
}
}
fun addRelay(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
}
here.counter++
}
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
live.refresh()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
updatedMetadataAt = updateAt
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
live.refresh()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.toLowerCase()?.startsWith("lnurl") == true) {
try {
val url = String(Bech32.decodeBytes(newUserInfo.lud06!!, false).second)
val matcher = lnurlpPattern.matcher(url)
while (matcher.find()) {
val domain = matcher.group(2)
val username = matcher.group(3)
info?.lud16 = "$username@$domain"
}
} catch (t: Throwable) {
// Doesn't create errors.
}
}
liveSet?.metadata?.invalidateData()
}
// Observers line up here.
val live: UserLiveData = UserLiveData(this)
fun isFollowing(user: User): Boolean {
return follows.contains(user)
}
private fun refreshObservers() {
live.refresh()
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this == it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type)
} != null
}
fun anyNameStartsWith(username: String): Boolean {
return info?.anyNameStartsWith(username) ?: false
}
var liveSet: UserLiveSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
liveSet = null
}
}
}
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
val relayInfo: UserLiveData = UserLiveData(u)
val metadata: UserLiveData = UserLiveData(u)
val zaps: UserLiveData = UserLiveData(u)
fun isInUse(): Boolean {
return follows.hasObservers()
|| reports.hasObservers()
|| messages.hasObservers()
|| relays.hasObservers()
|| relayInfo.hasObservers()
|| metadata.hasObservers()
|| zaps.hasObservers()
}
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
data class Chatroom(var roomMessages: Set<Note>)
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
@@ -77,22 +339,54 @@ class UserMetadata {
var iris: String? = null
var main_relay: String? = null
var twitter: String? = null
var updatedMetadataAt: Long = 0;
var latestMetadata: MetadataEvent? = null
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(UserState(user))
}
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user.pubkeyHex)
NostrSingleUserDataSource.add(user)
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user.pubkeyHex)
NostrSingleUserDataSource.remove(user)
}
}
class UserState(val user: User)

View File

@@ -1,10 +0,0 @@
package com.vitorpamplona.amethyst.service
import java.util.UUID
import nostr.postr.JsonFilter
data class Channel (
val id: String = UUID.randomUUID().toString().substring(0,4)
) {
var filter: JsonFilter? = null // Inactive when null
}

View File

@@ -1,24 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.Relay
object Constants {
val defaultRelays = arrayOf(
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
//Relay("wss://brb.io", read = true, write = true),
Relay("wss://nostr.v0l.io", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
Relay("wss://nostr.oxtr.dev", read = true, write = true),
Relay("wss://nostr-relay.wlvs.space", read = true, write = true),
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
Relay("wss://nostr.mom", read = true, write = true),
Relay("wss://nostr.orangepill.dev", read = true, write = true),
//Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
Relay("wss://nostr.onsats.org", read = true, write = true),
Relay("wss://nostr.sandwich.farm", read = true, write = true)
)
}

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
class Nip19 {
enum class Type {
USER, NOTE
}
data class Return(val type: Type, val hex: String)
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:")
if (key != null) {
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return Return(Type.USER, bytes.toHexKey())
}
if (key.startsWith("note")) {
return Return(Type.NOTE, bytes.toHexKey())
}
if (key.startsWith("nprofile")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
if (key.startsWith("nevent")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
}
} catch (e: Throwable) {
println("Trying to Decode NIP19: ${uri}")
e.printStackTrace()
}
return null
}
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var 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.put(t, mutableListOf())
}
result.get(t)?.add(v)
}
return result
}
}

View File

@@ -3,72 +3,73 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrAccountDataSource: NostrDataSource("AccountData") {
lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
resetFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener)
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener)
}
fun createAccountFilter(): JsonFilter {
return JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4), // 4 days
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
}
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, ChannelMessageEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)
)
val accountChannel = requestNewChannel()
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return list1.size == list2.size && list1.toSet() == list2.toSet()
}
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalsIgnoreOrder(list1.authors, list2.authors)
}
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
.sortedBy { it.event!!.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
val newAccountFilter = createAccountFilter()
if (!equalAuthors(newAccountFilter, accountChannel.filter)) {
accountChannel.filter = newAccountFilter
}
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter()
).ifEmpty { null }
}
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
}
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}

View File

@@ -0,0 +1,61 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomDataSource: NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.users[userId]
resetFilters()
}
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
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))
)
)
} else {
null
}
}
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
)
)
} else {
null
}
}
val inandoutChannel = requestNewChannel()
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
}

View File

@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
fun createMessagesFromMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createChannelsCreatedbyMeFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createMyChannelsFilter() = TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
)
)
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
val chatroomListChannel = requestNewChannel()
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
)
chatroomListChannel.typedFilters = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()
).flatten().ifEmpty { null }
}
}

View File

@@ -2,11 +2,29 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
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.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.service.relays.hasValidSignature
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
@@ -16,26 +34,67 @@ import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource(val debugName: String) {
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter:Int)
private var eventCounter = mapOf<String, Counter>()
fun printCounter() {
eventCounter.forEach {
println("AAA Count ${it.key}: ${it.value.counter}")
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channelIds) {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is RepostEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "${debugName} ${subscriptionId} ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event, relay)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is DeletionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> {
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(repostEvent)
}
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
LnZapEvent.kind -> {
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
zapEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(zapEvent)
}
LnZapRequestEvent.kind -> LocalCache.consume(LnZapRequestEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -43,7 +102,7 @@ abstract class NostrDataSource(val debugName: String) {
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -51,11 +110,14 @@ abstract class NostrDataSource(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
//}}")
/*
if (type == Relay.Type.EOSE) {
// One everything is loaded, if new users are found, update filters
resetFilters()
}*/
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(Date().time / 1000)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
}
@@ -68,62 +130,77 @@ abstract class NostrDataSource(val debugName: String) {
}
open fun stop() {
channels.forEach { channel ->
if (channel.filter != null) // if it is active, close
Client.close(channel.id)
subscriptions.values.forEach { channel ->
Client.close(channel.id)
channel.typedFilters = null
}
}
fun loadTop(): List<Note> {
return feed().take(100)
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun requestNewChannel(): Channel {
val newChannel = Channel()
channels.add(newChannel)
channelIds.add(newChannel.id)
return newChannel
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
fun dismissChannel(channel: Channel) {
Client.close(channel.id)
channels.remove(channel)
channelIds.remove(channel.id)
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFiltersSuspend()
handlerWaiting = false
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
resetFiltersSuspend()
}
}
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.toJson() }
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
channels.forEach { channel ->
val channelsNewFilter = channel.filter
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
if (channel in activeChannels) {
if (channelsNewFilter == null) {
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(channel.id)
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
Client.close(channel.id)
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(channel.id, mutableListOf(channelsNewFilter))
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
} else {
if (channelsNewFilter == null) {
if (updatedSubscriotionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
}
@@ -131,5 +208,4 @@ abstract class NostrDataSource(val debugName: String) {
}
abstract fun updateChannelFilters()
abstract fun feed(): List<Note>
}

View File

@@ -1,47 +1,27 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
val fifteenMinutes = (60*15) // 15 mins
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
since = System.currentTimeMillis() / 1000 - fifteenMinutes
lateinit var account: Account
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
fun equalTime(list1:Long?, list2:Long?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return Math.abs(list1 - list2) < (4*fifteenMinutes)
}
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalTime(list1.since, list2.since)
}
override fun feed() = LocalCache.notes.values
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
}
.sortedBy { it.event!!.createdAt }
.reversed()
override fun updateChannelFilters() {
val newFilter = createGlobalFilter()
if (!equalFilters(newFilter, globalFeedChannel.filter)) {
globalFeedChannel.filter = newFilter
}
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -2,9 +2,16 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -13,68 +20,49 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") {
lateinit var account: Account
private val cacheListener: (UserState) -> Unit = {
resetFilters()
invalidateFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().live.observeForever(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.observeForever(cacheListener)
}
}
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().live.removeObserver(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
}
fun createFollowAccountsFilter(): JsonFilter? {
val follows = account.userProfile().follows?.map {
it.pubkey.toHex().substring(0, 6)
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val followKeys = follows.map {
it.pubkeyHex.substring(0, 6)
}
if (follows == null || follows.isEmpty()) return null
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = follows,
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = followSet,
limit = 400
)
)
}
val followAccountChannel = requestNewChannel()
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return list1.size == list2.size && list1.toSet() == list2.toSet()
}
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalsIgnoreOrder(list1.authors, list2.authors)
}
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in follows }
.sortedBy { it.event!!.createdAt }
.reversed()
}
override fun updateChannelFilters() {
val newFollowAccountsFilter = createFollowAccountsFilter()
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter)) {
followAccountChannel.filter = newFollowAccountsFilter
}
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
}

View File

@@ -1,48 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 2 days
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
)
val notificationChannel = requestNewChannel()
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return list1.size == list2.size && list1.toSet() == list2.toSet()
}
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalsIgnoreOrder(list1.tags?.get("p"), list2.tags?.get("p"))
&& equalsIgnoreOrder(list1.tags?.get("e"), list2.tags?.get("e"))
}
override fun feed(): List<Note> {
return account.userProfile().taggedPosts
.filter { it.event != null }
.sortedBy { it.event!!.createdAt }
.reversed()
}
override fun updateChannelFilters() {
val newFilter = createGlobalFilter()
if (!equalFilters(newFilter, notificationChannel.filter)) {
notificationChannel.filter = newFilter
}
}
}

View File

@@ -0,0 +1,65 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
)
}
val searchChannel = requestNewChannel()
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(eventId: String) {
try {
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
decodePublicKey(eventId).toHex()
} else if (eventId.startsWith("note")) {
eventId.bechToBytes().toHex()
} else {
eventId
}
hexToWatch = hex
invalidateFilters()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
}
}
fun clear() {
hexToWatch = null
}
}

View File

@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
return null
}
// downloads all the reactions to a given event.
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
)
}
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
)
)
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
}
fun add(eventId: String) {
channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: String) {
channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters()
}
}

View File

@@ -2,69 +2,105 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import java.util.Collections
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.LnZapEvent
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
if (reactionsToWatch.isEmpty()) {
return null
}
return JsonFilter(
tags = mapOf("e" to reactionsToWatch)
)
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
)
)
}
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.map { LocalCache.getOrCreateNote(it) }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex.substring(0, 8) }
.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
return JsonFilter(
ids = interestedEvents
// downloads linked events to this event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
)
)
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
val singleEventChannel = requestNewChannel { time ->
eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
}
fun add(eventId: String) {
eventsToWatch.add(eventId)
resetFilters()
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: String) {
eventsToWatch.remove(eventId)
resetFilters()
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}

View File

@@ -1,42 +1,66 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import java.util.Collections
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
val usersToWatch = Collections.synchronizedList(mutableListOf<String>())
var usersToWatch = setOf<User>()
fun createUserFilter(): JsonFilter? {
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = usersToWatch.map { it.substring(0, 8) }
)
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
}
val userChannel = requestNewChannel()
fun createUserReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
override fun feed(): List<Note> {
return usersToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestReportTime
)
)
}
}
val userChannel = requestNewChannel(){
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.filter = createUserFilter()
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
}
fun add(userId: String) {
usersToWatch.add(userId)
resetFilters()
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
fun remove(userId: String) {
usersToWatch.remove(userId)
resetFilters()
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}

View File

@@ -2,81 +2,48 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import java.util.Collections
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
private var eventToWatch: String? = null
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null
if (reactionsToWatch.isEmpty()) {
return null
}
return JsonFilter(
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val eventsToLoad = eventsToWatch
.map { LocalCache.notes[it] }
.filterNotNull()
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
.map { it.idHex }
.toSet()
.ifEmpty { null } ?: return null
if (eventsToLoad.isEmpty()) {
return null
}
return JsonFilter(
ids = eventsToLoad
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
val loadEventsChannel = requestNewChannel(){
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId] ?: return
fun loadThread(noteId: String?) {
eventToWatch = noteId
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
val threadRoot = note.replyTo?.firstOrNull() ?: note
loadDown(threadRoot, thread, threadSet)
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
resetFilters()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
if (note !in threadSet) {
thread.add(note)
threadSet.add(note)
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}
invalidateFilters()
}
}

View File

@@ -0,0 +1,92 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters()
}
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 100
)
)
}
fun createUserReceivedZapsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(LnZapEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
)
)
}
fun createFollowFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createFollowersFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex))
)
)
}
val userInfoChannel = requestNewChannel()
override fun updateChannelFilters() {
userInfoChannel.typedFilters = listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter()
).ifEmpty { null }
}
}

View File

@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.service.lang
import android.util.LruCache
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import java.util.ArrayList
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == translateTo) {
Tasks.forCanceled()
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
translate(text, it, translateTo)
} else {
Tasks.forCanceled()
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class ChannelCreateEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channelInfo: ChannelData
init {
channelInfo = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null)
}
}
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = try {
if (channelInfo != null)
gson.toJson(channelInfo)
else
""
} catch (t: Throwable) {
Log.e("ChannelCreateEvent", "Couldn't parse channel information", t)
""
}
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = emptyList<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
}
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelHideMessageEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val eventsToHide: List<String>
init {
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val tags =
messagesToHide?.map {
listOf("e", it)
} ?: emptyList()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}

View File

@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelMessageEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
init {
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf(
listOf("e", channel, "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMetadataEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val channelInfo: ChannelCreateEvent.ChannelData
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
channelInfo =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelCreateEvent.ChannelData(null, null, null)
}
}
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
val content =
if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}

View File

@@ -0,0 +1,39 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMuteUserEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val usersToMute: List<String>
init {
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 44
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val tags =
usersToMute?.map {
listOf("p", it)
} ?: emptyList()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}

View File

@@ -0,0 +1,50 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
@Transient val containedPost: Event?
@Transient val lnInvoice: String?
@Transient val preimage: String?
@Transient val amount: BigDecimal?
init {
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
lnInvoice = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
amount = lnInvoice?.let { LnInvoiceUtil.getAmountInSats(lnInvoice) }
preimage = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
val description = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
containedPost = try {
if (description == null)
null
else
fromJson(description, Client.lenient)
} catch (e: Exception) {
null
}
}
companion object {
const val kind = 9735
}
}

View File

@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapRequestEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val zappedPost: List<String>
@Transient val zappedAuthor: List<String>
init {
zappedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
zappedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 9734
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf(
listOf("e", originalNote.id.toHex()),
listOf("p", originalNote.pubKey.toHex()),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf(
listOf("p", userHex),
listOf("relays") + relays
)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
]
]
}
*/

View File

@@ -25,8 +25,15 @@ class ReactionEvent (
companion object {
const val kind = 7
fun create(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val content = "+"
fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
val id = generateId(pubKey, createdAt, kind, tags, content)

View File

@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.ui.text.toUpperCase
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
// NIP 56 event.
class ReportEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 1984
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
}
}
enum class ReportType() {
EXPLICIT,
ILLEGAL,
SPAM,
IMPERSONATION
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
@@ -16,17 +17,24 @@ class RepostEvent (
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
@Transient val containedPost: Event?
init {
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
containedPost = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
}
companion object {
const val kind = 6
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = ""
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id.toHex())
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())

View File

@@ -1,10 +1,9 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.JsonFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
@@ -23,48 +22,54 @@ object Client: RelayPool.Listener {
* something.
**/
var lenient: Boolean = false
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
internal var relays = Constants.defaultRelays
internal val subscriptions = ConcurrentHashMap<String, MutableList<JsonFilter>>()
private var listeners = setOf<Listener>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
fun connect(
relays: Array<Relay> = Constants.defaultRelays
) {
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
}
fun requestAndWatch(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
) {
subscriptions[subscriptionId] = filters
RelayPool.requestAndWatch()
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
if (relays.size != newRelayConfig.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url }
if (newRelayInfo == null) return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
}
return true
}
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions[subscriptionId] = filters
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: Event) {
RelayPool.send(signedEvent)
}
fun close(subscriptionId: String){
fun close(subscriptionId: String) {
RelayPool.close(subscriptionId)
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
@@ -74,25 +79,52 @@ object Client: RelayPool.Listener {
}
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
}
fun subscribe(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unsubscribe(listener: Listener): Boolean {
return listeners.remove(listener)
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
}
fun allSubscriptions(): List<String> {
return subscriptions.keys.toList()
}
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
return subscriptions[subId] ?: emptyList()
}
abstract class Listener {
/**
@@ -108,6 +140,11 @@ object Client: RelayPool.Listener {
/**
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.Type, relay: Relay) = Unit
open fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) = Unit
/**
* When an relay saves or rejects a new event.
*/
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
}
}

View File

@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
val defaultRelays = arrayOf(
// Free relays
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}

View File

@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.service.relays
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.events.Event
import nostr.postr.events.generateId
fun Event.hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
if (!Secp256k1.get().verifySchnorr(sig, id, pubKey)) {
return false
}
return true
}

View File

@@ -1,8 +1,9 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import java.util.Collections
import nostr.postr.JsonFilter
import java.util.Date
import java.util.concurrent.TimeUnit
import nostr.postr.events.Event
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -10,132 +11,206 @@ import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
}
class Relay(
val url: String,
var url: String,
var read: Boolean = true,
var write: Boolean = true
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
private val httpClient = OkHttpClient()
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
private val httpClient = OkHttpClient.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build();
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
var closingTime = 0L
fun register(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
fun isConnected(): Boolean {
return socket != null
}
fun unregister(listener: Listener) = listeners.remove(listener)
@Synchronized
fun requestAndWatch() {
if (socket != null) return
fun requestAndWatch(reconnectTs: Long? = null) {
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
try {
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Sends everything.
Client.subscriptions.forEach {
sendFilter(requestId = it.key, reconnectTs = reconnectTs)
override fun onOpen(webSocket: WebSocket, response: Response) {
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
val channel = msg[1].asString
when (type) {
"EVENT" -> {
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
override fun onMessage(webSocket: WebSocket, text: String) {
try {
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
val type = msg[0].asString
val channel = msg[1].asString
when (type) {
"EVENT" -> {
//Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
"EOSE" -> listeners.forEach {
//Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
//Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
}
"OK" -> listeners.forEach {
//Log.w("Relay", "Relay onOK $url, $channel")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].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")
)
}
}
"EOSE" -> listeners.forEach {
it.onRelayStateChange(this@Relay, Type.EOSE)
}
"NOTICE" -> listeners.forEach {
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
}
"OK" -> listeners.forEach {
// "channel" being the second string in the string array ...
// Event was saved correctly?
}
else -> listeners.forEach {
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")) }
}
}
} catch (t: Throwable) {
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
listeners.forEach { it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
null
) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
isReady = false
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
errorCounter++
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
isReady = false
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure $url, ${response?.message} ${response}")
t.printStackTrace()
text.chunked(2000) { chunked ->
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECTING) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
}
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
errorCounter++
isReady = false
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
}
socket = httpClient.newWebSocket(request, listener)
}
fun disconnect() {
//httpClient.dispatcher.executorService.shutdown()
closingTime = Date().time / 1000
socket?.close(1000, "Normal close")
socket = null
isReady = false
}
fun sendFilter(requestId: String, reconnectTs: Long? = null) {
if (socket == null) {
requestAndWatch(reconnectTs)
} else {
val filters = if (reconnectTs != null) {
Client.subscriptions[requestId]?.let {
it.map { filter ->
JsonFilter(filter.ids, filter.authors, filter.kinds, filter.tags, since = reconnectTs)
fun sendFilter(requestId: String) {
if (read) {
if (isConnected()) {
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
//println("FILTERSSENT ${url} ${request}")
socket?.send(request)
}
} ?: error("No filter(s) found.")
}
} else {
Client.subscriptions[requestId] ?: error("No filter(s) found.")
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
// sends all filters after connection is successful.
requestAndWatch()
}
}
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
}
}
fun sendFilterOnlyIfDisconnected(requestId: String, reconnectTs: Long? = null) {
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
requestAndWatch(reconnectTs)
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}
}
}
fun send(signedEvent: Event) {
if (write)
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
}
}
fun close(subscriptionId: String){
socket?.send("""["CLOSE","$subscriptionId"]""")
}
fun isSameRelayConfig(other: Relay): Boolean {
return url == other.url
&& write == other.write
&& read == other.read
&& activeTypes == other.activeTypes
}
enum class Type {
// Websocket connected
CONNECT,
@@ -155,11 +230,12 @@ class Relay(
fun onError(relay: Relay, subscriptionId: String, error: Error)
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
/**
* Connected to or disconnected from a relay
*
* @param type is 0 for disconnect and 1 for connect
*/
fun onRelayStateChange(relay: Relay, type: Type)
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}

View File

@@ -1,32 +1,45 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool: Relay.Listener {
private val relays = Collections.synchronizedList(ArrayList<Relay>())
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
fun report(): String {
val connected = relays.filter { it.isConnected() }
return "${connected.size}/${relays.size}"
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
fun availableRelays(): Int {
return relays.size
}
fun loadRelays(relayList: List<Relay>? = null){
fun connectedRelays(): Int {
return relays.filter { it.isConnected() }.size
}
fun getRelay(url: String): Relay? {
return relays.firstOrNull() { it.url == url }
}
fun loadRelays(relayList: List<Relay>){
if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) }
} else {
Constants.defaultRelays.forEach { addRelay(it) }
Constants.convertDefaultRelays().forEach { addRelay(it) }
}
}
fun unloadRelays() {
relays.toList().forEach { removeRelay(it) }
relays.forEach { it.unregister(this) }
relays = listOf()
}
fun requestAndWatch() {
@@ -37,8 +50,8 @@ object RelayPool: Relay.Listener {
relays.forEach { it.sendFilter(subscriptionId) }
}
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
fun sendFilterOnlyIfDisconnected() {
relays.forEach { it.sendFilterOnlyIfDisconnected() }
}
fun send(signedEvent: Event) {
@@ -58,19 +71,17 @@ object RelayPool: Relay.Listener {
relays += relay
}
fun removeRelay(relay: Relay): Boolean {
fun removeRelay(relay: Relay) {
relay.unregister(this)
return relays.remove(relay)
relays = relays.minus(relay)
}
fun getRelays(): List<Relay> = relays
fun register(listener: Listener) {
listeners.add(listener)
listeners = listeners.plus(listener)
}
fun unregister(listener: Listener): Boolean {
return listeners.remove(listener)
fun unregister(listener: Listener) {
listeners = listeners.minus(listener)
}
interface Listener {
@@ -78,7 +89,9 @@ object RelayPool: Relay.Listener {
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@Synchronized
@@ -91,16 +104,22 @@ object RelayPool: Relay.Listener {
refreshObservers()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
refreshObservers()
}
override fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) {
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
}
// Observers line up here.
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
live.refresh()
scope.launch {
live.refresh()
}
}
}

View File

@@ -0,0 +1,30 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import java.util.UUID
data class Subscription (
val id: String = UUID.randomUUID().toString().substring(0,4),
val onEOSE: ((Long) -> Unit)? = null
) {
var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
}
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.addProperty("id", id)
typedFilters?.run {
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
}
return jsonObject
}
}

View File

@@ -0,0 +1,56 @@
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 nostr.postr.JsonFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: JsonFilter
) {
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("types", typesToJson(types))
jsonObject.add("filter", filterToJson(filter))
return jsonObject
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
}
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) } })
}
}
filter.since?.run {
jsonObject.addProperty("since", filter.since)
}
filter.until?.run {
jsonObject.addProperty("until", filter.until)
}
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
return jsonObject
}
}

View File

@@ -1,5 +1,8 @@
package com.vitorpamplona.amethyst.ui
import android.content.ComponentCallbacks2
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -8,33 +11,64 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.KeyStorage
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.bechToBytes
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val nip19 = Nip19().uriToRoute(intent?.data?.toString())
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
else -> null
}
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} //.logger(DebugLogger())
.respectCacheHeaders(false)
.build()
}
setContent {
AmethystTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
AccountStateViewModel(LocalPreferences(applicationContext))
}
AccountScreen(accountViewModel)
AccountScreen(accountViewModel, startingPage)
}
}
}
@@ -44,19 +78,23 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
Client.connect()
// Only starts after login
ServiceManager.start()
}
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
ServiceManager.pause()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
Client.disconnect()
super.onPause()
}
}
/**
* Release memory when the UI becomes hidden or when system resources become low.
* @param level the memory-related event that was raised.
*/
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
println("Trim Memory $level")
ServiceManager.cleanUp()
}
}

View File

@@ -0,0 +1,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}

View File

@@ -0,0 +1,76 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
object ImageUploader {
fun uploadImage(
uri: Uri,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit,
) {
val contentType = contentResolver.getType(uri)
val client = OkHttpClient.Builder().build()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
}
}
)
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(requestBody)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
onSuccess(url)
}
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
})
}
}

View File

@@ -0,0 +1,131 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
val context = LocalContext.current.applicationContext
postViewModel.load(account, channel)
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create(context)
onClose()
},
postViewModel.channelName.value.text.isNotBlank()
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Channel Name") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = "My Awesome Group",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Picture Url") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
placeholder = {
Text(
text = "http://mygroup.com/logo.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Description") },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = "About us.. ",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10
)
}
}
}
}

View File

@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
private var originalChannel: Channel? = null
val channelName = mutableStateOf(TextFieldValue())
val channelPicture = mutableStateOf(TextFieldValue())
val channelDescription = mutableStateOf(TextFieldValue())
fun load(account: Account, channel: Channel?) {
this.account = account
if (channel != null) {
originalChannel = channel
channelName.value = TextFieldValue(channel.info.name ?: "")
channelPicture.value = TextFieldValue(channel.info.picture ?: "")
channelDescription.value = TextFieldValue(channel.info.about ?: "")
}
}
fun create(context: Context) {
this.account?.let { account ->
if (originalChannel == null) {
account.sendCreateNewChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text
)
LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text,
channelDescription.value.text,
channelPicture.value.text,
originalChannel!!
)
}
clear()
}
fun clear() {
channelName.value = TextFieldValue()
channelPicture.value = TextFieldValue()
channelDescription.value = TextFieldValue()
}
}

View File

@@ -1,131 +1,216 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
class PostViewModel: ViewModel() {
var account: Account? = null
var message by mutableStateOf("")
var replyingTo: Note? = null
fun sendPost() {
account?.sendPost(message, replyingTo)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
val postViewModel: PostViewModel = viewModel<PostViewModel>().apply {
this.replyingTo = replyingTo
this.account = account
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
val dialogProperties = DialogProperties()
Dialog(
onDismissRequest = { onClose() }, properties = dialogProperties
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = onClose)
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
}
)
}
if (replyingTo != null && replyingTo.event is TextNoteEvent) {
Surface(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.padding(10.dp).imePadding().weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val replyList = replyingTo.replyTo!!.plus(replyingTo).joinToString(", ", "", "", 2) { it.idDisplayHex }
val withList = replyingTo.mentions!!.plus(replyingTo.author!!).joinToString(", ", "", "", 2) { it.toBestDisplayName() }
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
Text(
"in reply to ${replyList} with ${withList}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
) {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.message.text.isNotBlank()
&& !postViewModel.isUploadingImage
)
}
Row(
modifier = Modifier.fillMaxWidth().weight(1f)
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroolState)) {
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
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 = "What's on your mind?",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl)
.matches()
) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}
}
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
}
}
}
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = { postViewModel.message = it },
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier.fillMaxWidth().fillMaxHeight()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
),
placeholder = {
Text(
text = "What's on your mind?",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
)
)
}
}
}
}
@Composable
private fun CloseButton(onCancel: () -> Unit) {
fun CloseButton(onCancel: () -> Unit) {
Button(
onClick = {
onCancel()
@@ -141,17 +226,82 @@ private fun CloseButton(onCancel: () -> Unit) {
}
@Composable
private fun PostButton(onPost: () -> Unit = {}) {
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
onPost()
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Post", color = Color.White)
}
}
@Composable
fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}
@Composable
fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Create", color = Color.White)
}
}
@Composable
fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Icon(
painter = painterResource(R.drawable.ic_search),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}

View File

@@ -0,0 +1,187 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import nostr.postr.toNpub
class NewPostViewModel: ViewModel() {
private var account: Account? = null
private var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
}
this.account = account
}
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.type == "npub") {
addUserToMentions(LocalCache.getOrCreateUser(results.keyHex))
} else if (results?.type == "note") {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.keyHex))
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.type == "npub") {
val user = LocalCache.getOrCreateUser(results.keyHex)
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.type == "note") {
val note = LocalCache.getOrCreateNote(results.keyHex)
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
if (originalNote?.channel != null) {
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
}
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun upload(it: Uri, context: Context) {
isUploadingImage = true
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
}
}
)
}
fun cancel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
}
}
}
fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it)
}
fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
} else {
userSuggestions = emptyList()
}
}
}
fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkeyNpub()} "
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
)
userSuggestionAnchor = null
userSuggestions = emptyList()
}
}
}

View File

@@ -0,0 +1,438 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val ctx = LocalContext.current.applicationContext
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account, ctx)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear(ctx)
onClose()
})
PostButton(
onPost = {
postViewModel.create(ctx)
onClose()
},
true
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
) {
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
if (index == 0)
ServerConfigHeader()
ServerConfig(item,
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onDelete = { postViewModel.deleteRelay(it) }
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
EditableServerConfig(relayToAdd) {
postViewModel.addRelay(it)
}
}
}
}
}
@Composable
fun ServerConfigHeader() {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Relay Address",
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Posts",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Errors",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(5.dp))
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun ServerConfig(
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
Column() {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = Color.Red
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.url.removePrefix("wss://"),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleFollows(item) }
) {
Icon(
painterResource(R.drawable.ic_home),
"Home Feed",
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePrivateDMs(item) }
) {
Icon(
painterResource(R.drawable.ic_dm),
"Private Message Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePublicChats(item) }
) {
Icon(
imageVector = Icons.Default.Groups,
"Public Chat Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleGlobal(item) }
) {
Icon(
imageVector = Icons.Default.Public,
"Global Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Add a Relay") },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
},
singleLine = true
)
IconButton(onClick = { read = !read }) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
IconButton(onClick = { write = !write }) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Text(text = "Add", color = Color.White)
}
}
}
fun countToHumanReadable(counter: Int) = when {
counter >= 1000000000 -> "${round(counter/1000000000f)}G"
counter >= 1000000 -> "${round(counter/1000000f)}M"
counter >= 1000 -> "${round(counter/1000f)}k"
else -> "$counter"
}

View File

@@ -0,0 +1,121 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
this.account = account
clear(ctx)
}
fun create(ctx: Context) {
relays.let {
account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
clear(ctx)
}
fun clear(ctx: Context) {
_relays.update {
val relayFile = account.userProfile().relays
if (relayFile != null)
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
it.plus(relay)
}
}
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
fun <T> togglePresenceInSet(set: Set<T>, item: T): Set<T> {
return if (set.contains(item)) set.minus(item) else set.plus(item)
}

View File

@@ -0,0 +1,227 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
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.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
val postViewModel: NewUserMetadataViewModel = viewModel()
LaunchedEffect(Unit) {
postViewModel.load(account)
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
)
) {
Surface(
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
onClose()
})
PostButton(
onPost = {
postViewModel.create()
onClose()
},
postViewModel.userName.value.isNotBlank()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
value = postViewModel.about.value,
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
maxLines = 10
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/me.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
placeholder = {
Text(
text = "https://mywebsite.com/mybanner.jpg",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
placeholder = {
Text(
text = "https://mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
placeholder = {
Text(
text = "_@mywebsite.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
placeholder = {
Text(
text = "me@mylightiningnode.com",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ObjectNode
import com.vitorpamplona.amethyst.model.Account
import java.io.ByteArrayInputStream
import java.io.StringWriter
class NewUserMetadataViewModel: ViewModel() {
private lateinit var account: Account
val userName = mutableStateOf("")
val displayName = mutableStateOf("")
val about = mutableStateOf("")
val picture = mutableStateOf("")
val banner = mutableStateOf("")
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
website.value = it.info?.website ?: ""
nip05.value = it.info?.nip05 ?: ""
lnAddress.value = it.info?.lud16 ?: ""
lnURL.value = it.info?.lud06 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().info?.latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
) as ObjectNode
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value.trim())
currentJson.put("username", userName.value.trim())
currentJson.put("display_name", displayName.value.trim())
currentJson.put("displayName", displayName.value.trim())
currentJson.put("picture", picture.value.trim())
currentJson.put("banner", banner.value.trim())
currentJson.put("website", website.value.trim())
currentJson.put("about", about.value.trim())
currentJson.put("nip05", nip05.value.trim())
currentJson.put("lud16", lnAddress.value.trim())
currentJson.put("lud06", lnURL.value.trim())
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString())
clear()
}
fun clear() {
userName.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
banner.value = ""
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}

View File

@@ -0,0 +1,100 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(
isUploading: Boolean,
onImageChosen: (Uri) -> Unit,
) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
GallerySelect(
onImageUri = { uri ->
showGallerySelect = false
if (uri != null)
onImageChosen(uri)
}
)
} else {
Box() {
Button(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(4.dp),
enabled = !isUploading,
onClick = {
showGallerySelect = true
}
) {
if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
}
}
}
} else {
Column {
Button(
onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading,
) {
if (!isUploading) {
Text("Upload Image")
} else {
Text("Uploading…")
}
}
}
}
}
@Composable
fun GallerySelect(
onImageUri: (Uri?) -> Unit = { }
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
onResult = { uri: Uri? ->
onImageUri(uri)
}
)
@Composable
fun LaunchGallery() {
SideEffect {
launcher.launch("image/*")
}
}
LaunchGallery()
}

View File

@@ -0,0 +1,144 @@
package com.vitorpamplona.amethyst.ui.actions
import android.util.Patterns
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
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 kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
class UrlUserTagTransformation(val color: Color) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return buildAnnotatedStringWithUrlHighlighting(text, color)
}
}
fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText {
val substitutions = mutableListOf<RangesChanges>()
val newText = buildAnnotatedString {
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
append(
text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val startIndex = builderBefore.toString().length
builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph
substitutions.add(
RangesChanges(
TextRange(startIndex, endIndex),
TextRange(startNew, startNew + newWord.length)
)
)
newWord + restOfWord
} else {
builderBefore.append(word + " ")
builderAfter.append(word + " ")
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
builderBefore.append(word + " ")
builderAfter.append(word + " ")
word
}
}.joinToString(" ")
}.joinToString("\n")
)
val newText = toAnnotatedString()
newText.split("\\s+".toRegex()).filter { word ->
Patterns.WEB_URL.matcher(word).matches()
}.forEach {
val startIndex = text.indexOf(it)
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = startIndex, end = endIndex
)
}
substitutions.forEach {
addStyle(
style = SpanStyle(
color = color,
textDecoration = TextDecoration.None
),
start = it.modified.start, end = it.modified.end
)
}
}
val numberOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull()
if (inInsideRange != null) {
val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
if (lastRangeThrough != null) {
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
} else {
return offset
}
}
override fun transformedToOriginal(offset: Int): Int {
val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull()
if (inInsideRange != null) {
val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt()
}
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
if (lastRangeThrough != null) {
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
} else {
return offset
}
}
}
return TransformedText(
newText,
numberOffsetTranslator
)
}

View File

@@ -0,0 +1,53 @@
package com.vitorpamplona.amethyst.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost)
NewChannelView({ wantsToPost = false }, account = account)
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = "New Channel",
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}

View File

@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.LocalImageLoader
import java.net.URLEncoder
import java.util.Base64
data class ResizeImage(val url: String?, val size: Dp)
@Composable
fun AsyncImageProxy(
model: ResizeImage,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
error: Painter? = null,
fallback: Painter? = error,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
) {
if (model.url == null) {
AsyncImage(
model = model.url,
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
val imgPx = with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(model.url.toByteArray())
val extension = model.url.split(".").lastOrNull()
AsyncImage(
model = "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64",
contentDescription = contentDescription,
imageLoader = LocalImageLoader.current,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickableEmail(email: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$email "),
onClick = { runCatching { context.sendMail(email) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
fun Context.sendMail(to: String, subject: String? = null) {
try {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
if (subject != null)
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
startActivity(intent)
} catch (e: ActivityNotFoundException) {
// TODO: Handle case where no email app is available
} catch (t: Throwable) {
// TODO: Handle potential other type of exceptions
}
}

View File

@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
baesNote: Note,
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickablePhone(phone: String) {
val context = LocalContext.current
ClickableText(
text = AnnotatedString("$phone "),
onClick = { runCatching { context.dial(phone) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
fun Context.dial(phone: String) {
try {
val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null))
startActivity(intent)
} catch (t: Throwable) {
// TODO: Handle potential exceptions
}
}

View File

@@ -0,0 +1,59 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
navController: NavController
) {
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@${text} "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel != null) {
ClickableText(
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
}
}

View File

@@ -0,0 +1,19 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}

View File

@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
@Composable
fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, modifier, tags, accountViewModel, navController)
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
}

View File

@@ -0,0 +1,119 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
val amount = try {
LnInvoiceUtil.getAmountInSats(lnInvoice)
} catch (e: Exception) {
e.printStackTrace()
null
}
val context = LocalContext.current
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Invoice",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
amount?.let {
Text(
text = "${NumberFormat.getInstance().format(amount)} sats",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
startActivity(context, intent, null)
}
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Pay", color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@@ -0,0 +1,159 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.launch
@Composable
fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onClose: () -> Unit ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Tips",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
OutlinedTextField(
label = { Text(text = "Note to Receiver") },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = "Thank you so much!",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
OutlinedTextField(
label = { Text(text = "Amount in Sats") },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = {
runCatching {
if (it.isEmpty())
amount = 0
else
amount = it.toLong()
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message, zapRequest?.toJson(),
onSuccess = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
},
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
onClose()
}
}
)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@@ -1,30 +1,41 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile("\\#\\[([0-9]*)\\]")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
return try {
@@ -38,46 +49,116 @@ fun isValidURL(url: String?): Boolean {
}
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?) {
Column(modifier = Modifier.padding(top = 5.dp)) {
fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController,
) {
Column(modifier = modifier.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
// Explicit URL
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
)
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
UrlPreview(word, word)
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags)
} else {
Text(text = "$word ")
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
}
}
}
}
fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true)
|| word.startsWith("npub1", true)
|| word.startsWith("note1", true)
|| word.startsWith("nprofile1", true)
|| word.startsWith("nevent1", true)
|| word.startsWith("@npub1", true)
|| word.startsWith("@note1", true)
|| word.startsWith("@nprofile1", true)
|| word.startsWith("@nevent1", true)
}
@Composable
fun TagLink(word: String, tags: List<List<String>>) {
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:${word}"
}
val nip19Route = try {
Nip19().uriToRoute(uri)
} catch (e: Exception) {
null
}
if (nip19Route == null) {
Text(text = "$word ")
} else {
ClickableRoute(nip19Route, navController)
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
@@ -92,24 +173,34 @@ fun TagLink(word: String, tags: List<List<String>>) {
return Text(text = "$word ")
}
if (index > 0 && index < tags.size) {
if (index >= 0 && index < tags.size) {
if (tags[index][0] == "p") {
val user = LocalCache.users[tags[index][1]]
if (user != null) {
val innerUserState by user.live.observeAsState()
Text(
"${innerUserState?.user?.toBestDisplayName()}"
)
ClickableUserTag(user, navController)
} else {
Text(text = "${tags[index][1].toByteArray().toNpub().toShortenHex()} ")
}
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
if (note != null) {
val innerNoteState by note.live.observeAsState()
Text(
"${innerNoteState?.note?.idDisplayHex}"
)
//ClickableNoteTag(note, navController)
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f)),
isQuotedNote = true,
navController = navController)
} else {
Text(text = "${tags[index][1].toByteArray().toNote().toShortenHex()} ")
}
} else
Text(text = "$word ")
}
}
}

View File

@@ -0,0 +1,154 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Locale
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
modifier,
tags,
accountViewModel,
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
append("-translated from ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal")
showOriginal = span.item.toBoolean()
else
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
Text("Always translate to ${lang.displayName}")
}
}
}
}
}
}
}
}
}

View File

@@ -1,109 +1,50 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@Composable
fun UrlPreview(url: String, urlText: String) {
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(urlPreviewState) {
if (urlPreviewState == UrlPreviewState.Loading) {
val urlPreview = BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
LaunchedEffect(url) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
urlPreview.fetchUrlPreview()
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
}
val uri = LocalUriHandler.current
Crossfade(targetState = urlPreviewState) { state ->
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
Row(
modifier = Modifier.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
AsyncImage(
model = state.previewInfo.image,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = state.previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top= 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = state.previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
UrlPreviewCard(url, state.previewInfo)
}
else -> {
ClickableText(
text = AnnotatedString("$urlText "),
onClick = { runCatching { uri.openUri(url) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
if (showUrlIfError) {
ClickableUrl(urlText, url)
}
}
}
}
}
}

View File

@@ -0,0 +1,66 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
@Composable
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem
) {
val uri = LocalUriHandler.current
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column {
AsyncImage(
model = previewInfo.image,
contentDescription = "Preview Card Image for ${url}",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.ui.components
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.vitorpamplona.amethyst.VideoCache
@Composable
fun VideoView(videoUri: String) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
}
}
DisposableEffect(exoPlayer) {
exoPlayer.setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
)
exoPlayer.prepare()
onDispose {
exoPlayer.release()
}
}
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
}
})
}

View File

@@ -0,0 +1,59 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.gestures.forEachGesture
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
@Composable
fun ZoomableAsyncImage(imageUrl: String) {
var scale by remember { mutableStateOf(1f) }
var offsetX by remember { mutableStateOf(0f) }
var offsetY by remember { mutableStateOf(0f) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.pointerInput(Unit) {
forEachGesture {
awaitPointerEventScope {
awaitFirstDown()
do {
val event = awaitPointerEvent()
scale *= event.calculateZoom()
val offset = event.calculatePan()
offsetX += offset.x
offsetY += offset.y
} while (event.changes.any { it.pressed })
}
}
}
) {
AsyncImage(
model = imageUrl,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxSize().graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offsetX,
translationY = offsetY
),
)
}
}

View File

@@ -0,0 +1,78 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
fun ZoomableImageView(word: String) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
var dialogOpen by remember {
mutableStateOf(false)
}
AsyncImage(
model = word,
contentDescription = word,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(word)) },
)
)
if (dialogOpen) {
Dialog(
onDismissRequest = { dialogOpen = false },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
dialogOpen = false
})
SaveToGallery(url = word)
}
ZoomableAsyncImage(word)
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object ChannelFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
lateinit var channel: Channel
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) {
account = accountLoggedIn
channel = LocalCache.getOrCreateChannel(channelId)
}
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
}

View File

@@ -0,0 +1,28 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
object ChatroomFeedFilter: FeedFilter<Note>() {
var account: Account? = null
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.checkGetOrCreateUser(userId)
}
// returns the last Note of each user.
override fun feed(): List<Note> {
val myAccount = account
val myUser = withUser
if (myAccount == null || myUser == null) return emptyList()
val messages = myAccount.userProfile().privateChatrooms[myUser] ?: return emptyList()
return messages.roomMessages.filter { myAccount.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
}

View File

@@ -0,0 +1,33 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListKnownFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt
}?.lastOrNull {
it.event != null
}
}
val publicChannels = account.followingChannels().map {
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
}

View File

@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListNewFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
override fun feed(): List<Note> {
val me = ChatroomListKnownFeedFilter.account.userProfile()
val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
!me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
val privateMessages = messagingWith.mapNotNull {
privateChatrooms[it]?.roomMessages?.sortedBy {
it.event?.createdAt
}?.lastOrNull {
it.event != null
}
}
return privateMessages.sortedBy { it.event?.createdAt }.reversed()
}
}

View File

@@ -0,0 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
abstract class FeedFilter<T>() {
fun loadTop(): List<T> {
return feed().take(1000)
}
abstract fun feed(): List<T>
}

View File

@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object GlobalFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.sortedBy { it.event?.createdAt }
.reversed()
}

View File

@@ -0,0 +1,14 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object HiddenAccountsFeedFilter: FeedFilter<User>() {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
}

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