Compare commits

...
934 Commits
Author SHA1 Message Date
Vitor Pamplona 811c373e4c v0.27.0 2023-03-15 17:07:06 -04:00
Vitor Pamplona 1812ec296e Hashtags 2023-03-15 17:02:49 -04:00
Vitor Pamplona 59cdd81aec Shows LongForm Texts on Global 2023-03-15 17:02:13 -04:00
Vitor Pamplona 9297afc02a Deleting contact lists from pausing the app 2023-03-15 15:13:23 -04:00
Vitor Pamplona a8d8be3307 Removing unused mentions in LocalCache 2023-03-15 14:35:10 -04:00
Vitor Pamplona 53ec6c817c Fixes the deletion of private messages and messages in tags 2023-03-15 14:28:03 -04:00
Vitor Pamplona b7c4ec8358 Shows url host in the link preview 2023-03-15 14:06:26 -04:00
Vitor Pamplona 3426ff307c Makes sure onClick and onLongClick are null when their counterparts are as well. 2023-03-15 13:58:09 -04:00
Vitor Pamplona cddb713b2f Adds the following icon to the chat header 2023-03-15 13:52:26 -04:00
Vitor Pamplona 1871fe6177 Locally cache following Users in the Home & Conversations filter 2023-03-15 13:40:26 -04:00
Vitor Pamplona 83a8558f23 Caching followingChannels and followingUsers to speed Global filter up 2023-03-15 13:39:47 -04:00
Vitor Pamplona dbc155454f Checks for hex keys in the tag list before showing follows 2023-03-15 13:39:06 -04:00
Vitor Pamplona 80e251acfb Avoids creating User for all awardees of a badge. 2023-03-15 13:22:11 -04:00
Vitor Pamplona a597bd7882 Checks the mentions for a valid Hex before creating users in the cache. 2023-03-15 13:14:10 -04:00
Vitor Pamplona a53bb53c12 Avoids breaking urls and invoices when cutting an expandable content 2023-03-15 13:13:41 -04:00
Vitor Pamplona 4fa7aa54a8 Refactoring Note's hasReports function 2023-03-15 13:12:55 -04:00
Vitor Pamplona a9490e1299 Refactoring calls to check if a user is the signed in user. 2023-03-15 13:12:21 -04:00
Vitor Pamplona 2bf36f1cfe Implements a faster AuthorGallery for notifications 2023-03-15 11:39:30 -04:00
Vitor Pamplona c781d5eff7 Forces image size in profile pictures to reduce calculations before resizing the downloaded image. 2023-03-15 11:21:53 -04:00
Vitor Pamplona e8d84795d5 Reduces badge size in Profiles 2023-03-15 11:21:00 -04:00
Vitor Pamplona b351a781a9 Removes time measuring functions from RoboHash 2023-03-15 11:20:36 -04:00
Vitor Pamplona 97d9e96e2b Uses cached functions for isFollowing calculations 2023-03-15 11:20:09 -04:00
Vitor Pamplona 625b8c3bce Makes the reporting Menu less starkey in dark theme 2023-03-15 09:11:55 -04:00
Vitor PamplonaandGitHub edd4a4cc2c Merge pull request #275 from maxmoney21m/feature/216-merge-reports
Add separate screen for report & block
2023-03-15 08:09:07 -04:00
Vitor PamplonaandGitHub b305ccad1d Merge pull request #280 from maxmoney21m/bugfix/gitignore
Remove ignored file
2023-03-15 08:08:39 -04:00
Vitor PamplonaandGitHub 14ca6f1d08 Merge pull request #281 from maxmoney21m/bugfix/string-parameters
Add parameters for strings with variables
2023-03-15 08:08:05 -04:00
maxmoney21m ad4e6d6596 Add Block button and dialog 2023-03-15 13:40:59 +08:00
maxmoney21m e94d2b5340 Update gitignore 2023-03-15 11:29:05 +08:00
maxmoney21m 0241bf0913 Add report & block screen 2023-03-15 11:29:04 +08:00
maxmoney21m 36885eb6dc Add parameters for strings with variables 2023-03-15 11:26:51 +08:00
maxmoney21m c1917855d0 Remove ignored file 2023-03-15 10:42:53 +08:00
Vitor Pamplona 0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona 063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor PamplonaandGitHub 4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona 2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona 8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
Vitor Pamplona f8bb4a3168 v0.26.0 2023-03-14 18:30:48 -04:00
Vitor Pamplona 1b8923b6d7 Speeding up Notification and Global feed filters 2023-03-14 18:30:26 -04:00
Vitor PamplonaandGitHub 675b59c0d2 Merge pull request #233 from vitorpamplona/less_memory_test_branch
Restructuring User/Note
2023-03-14 17:56:38 -04:00
Vitor Pamplona a71078f90f Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-14 17:46:29 -04:00
Vitor Pamplona d4be271060 Fixing typos in the path 2023-03-14 16:27:28 -04:00
Vitor Pamplona 2d59baf898 Uses unique IDs for each job in Actions 2023-03-14 16:14:17 -04:00
Vitor Pamplona 18f651b67f v0.25.3 Testing Build Scripts 2023-03-14 16:03:44 -04:00
Vitor Pamplona f65e09f388 Adding scripts to release all variants of the app 2023-03-14 15:55:08 -04:00
Vitor PamplonaandGitHub a4bb01ef7d Merge pull request #276 from maxmoney21m/feature/54-fdroid-flavor
Make release channel flavors, remove mlkit from fdroid channel
2023-03-14 15:26:11 -04:00
maxmoney21m e9c0d350e1 Make release channel flavors, remove mlkit from fdroid channel 2023-03-15 02:34:50 +08:00
Vitor Pamplona faa29675db 0.25.2 2023-03-14 13:01:13 -04:00
Vitor PamplonaandGitHub 96990c44a6 Merge pull request #274 from vitorpamplona/pr/273
Pr/273
2023-03-14 12:19:46 -04:00
Vitor PamplonaandGitHub 424fe5fd3c Merge pull request #273 from believethehype/main
Image upload button in Chats
2023-03-14 12:19:17 -04:00
Vitor Pamplona 06d3784261 Moving Upload Image buttons to be inside the TextEdit 2023-03-14 12:02:25 -04:00
Believethehype 9e3c08ade8 Image upload button in Chats 2023-03-14 15:27:58 +01:00
Vitor Pamplona 3b7fc7173d This call already happens inside Login 2023-03-14 09:46:39 -04:00
Vitor Pamplona 105c7b9fe8 Avoids adding an empty string when the list is empty because it will parse it back as a list with 1 element with an empty npub. 2023-03-14 09:46:09 -04:00
Vitor PamplonaandGitHub c0c45d1385 Merge pull request #270 from Bardesss/patch-2
Update NL strings.xml
2023-03-14 08:48:17 -04:00
Vitor PamplonaandGitHub 9b2ce25668 Merge pull request #271 from maxmoney21m/bugfix/account-switch-tabs
Bugfix: Reset feedfilter accounts when switching accounts
2023-03-14 08:48:04 -04:00
Vitor PamplonaandGitHub 7e2b28503e Merge pull request #269 from maxmoney21m/bugfix/apptopbar-bgcolor
Fix AppTopBar background color
2023-03-14 08:47:37 -04:00
maxmoney21m d798db7cf1 Reset FeedFilter account when account changes 2023-03-14 18:23:04 +08:00
BardesssandGitHub aa2f718589 Update NL strings.xml
Added missing strings an fixed some mistakes.
2023-03-14 09:26:35 +00:00
maxmoney21m f3a4a7e143 Fix AppTopBar background color 2023-03-14 13:53:38 +08:00
Vitor Pamplona 732abb70f3 v0.25.1 2023-03-13 22:01:35 -04:00
Vitor Pamplona 8c98a8849c Adding the same button style for before and after granted permissions 2023-03-13 17:57:51 -04:00
Vitor Pamplona 5ce6d06920 Reverting QuickAction to the DropDown Menu until we get reporting functions implemented. 2023-03-13 17:46:09 -04:00
Vitor Pamplona bb6b4c208d Moves SVG generation to fetcher (IO) and avoids the use of TrimIndent, which is too slow (2-10ms each) for this context. 2023-03-13 17:38:30 -04:00
Vitor Pamplona ea63522745 Updating dependencies 2023-03-13 17:36:47 -04:00
Vitor Pamplona 698c9b00cb v0.25.0 2023-03-13 15:37:55 -04:00
Vitor Pamplona 1c740d4d7a Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
2023-03-13 14:51:44 -04:00
Vitor PamplonaandGitHub 0491c25e9f Merge pull request #268 from maxmoney21m/fix-all-warnings
Fixed (almost) all test warnings
2023-03-13 14:47:17 -04:00
Vitor Pamplona 75ed380449 Fixing merge issue 2023-03-13 14:32:21 -04:00
maxmoney21m 2820197905 Fixed (almost) all test warnings 2023-03-14 02:32:00 +08:00
Vitor Pamplona 01818ff458 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt
2023-03-13 14:09:17 -04:00
Vitor Pamplona d19592aef3 Activates .mov video previews 2023-03-13 13:49:06 -04:00
Vitor PamplonaandGitHub 9c4f9fce37 Merge pull request #267 from matata2140/patch-1
Update strings.xml
2023-03-13 13:37:27 -04:00
Vitor PamplonaandGitHub c06ed8d338 Merge pull request #258 from maxmoney21m/215-robohash-identicons
Generate robohash using SVGs
2023-03-13 13:36:05 -04:00
maxmoney21m 0f42c2707e Rebase on main / fix merge conflicts 2023-03-14 01:08:15 +08:00
maxmoney21m 5c518501ab Edit background shape, use painter placeholder/fallback
Use logo-shaped background

Use painter as placeholder and fallback
2023-03-14 01:07:48 +08:00
maxmoney21m 887d963c5b Remove original robohash dependency 2023-03-14 01:07:43 +08:00
maxmoney21m 0eb21a6650 Replace remaining avatars with robot fallback images 2023-03-14 01:07:42 +08:00
maxmoney21m 9f6867b6ab Switch PFPs to use the robohash proxy 2023-03-14 01:07:31 +08:00
maxmoney21m 6403bd21f8 Add async image proxy for robohash images 2023-03-14 00:53:22 +08:00
maxmoney21m b3f2a03295 Add RoboHash SVG generator 2023-03-14 00:53:21 +08:00
Vitor PamplonaandGitHub 27b2cda14f Merge pull request #249 from maxmoney21m/feature/zxing-qr-scanner
Replace mlkit with zxing QR scanner
2023-03-13 11:27:45 -04:00
Vitor PamplonaandGitHub f61ec0b5ab Merge pull request #266 from maxmoney21m/bugfix/okhttp-ua-header
Add user-agent header to OkHttpClient requests
2023-03-13 11:09:51 -04:00
maxmoney21m 53d692ec7f Add user-agent header, fixes #259 2023-03-13 23:02:53 +08:00
Vitor PamplonaandGitHub 12876cdacd Merge pull request #245 from maxmoney21m/feature/keep-feed-position
Keep feed position while navigating different screens
2023-03-13 10:55:54 -04:00
Vitor PamplonaandGitHub 63a14b7409 Merge pull request #265 from maxmoney21m/bugfix/gitignore
Update gitignore
2023-03-13 10:53:33 -04:00
Vitor Pamplona cd5e0187e8 Refining the selection size when clicking in an account. 2023-03-13 10:20:48 -04:00
maxmoney21m 02d74c611a Remove .idea files 2023-03-13 21:42:09 +08:00
maxmoney21m a367bf5982 Update gitignore 2023-03-13 21:41:02 +08:00
maxmoney21m d8c2e623d1 Hoist HomeScreen state 2023-03-13 20:28:30 +08:00
maxmoney21m 70434eb244 Remove Route buildScreen member 2023-03-13 20:17:35 +08:00
maxmoney21m 85d722f96d Hoist globalfeed state out of Screen component 2023-03-13 19:03:15 +08:00
maxmoney21m e4cec20d1a Make forceRefresh non-null 2023-03-13 13:07:01 +08:00
maxmoney21m bb7dcbdb78 Resolve merge conflicts on main 2023-03-13 11:35:08 +08:00
matataandGitHub 260ed29309 Update strings.xml
I wasn't sure about translating 'channel' so I left them as is, but:
If 'channel' refers to 'dm group-chat' then go on and replace all 'kanaal' with 'groep'
2023-03-13 00:12:00 +01:00
Vitor PamplonaandGitHub 9fc1fa9f6c Merge pull request #263 from maxmoney21m/feature/multiple-accounts
Multiple account management
2023-03-12 18:09:50 -04:00
Vitor PamplonaandGitHub 199f9fbe69 Merge pull request #264 from Chemaclass/refactor-LocalCache-isValidHexNpub
Refactor LocalCache.isValidHexNpub()
2023-03-12 17:57:16 -04:00
Chemaclass 2fea7dac62 Refactor LocalCache.isValidHexNpub() 2023-03-12 18:42:17 +01:00
maxmoney21m 90147ce557 Allow tapping nav icon to refresh & scroll to top 2023-03-12 22:40:26 +08:00
maxmoney21m 4fa584ca1a Remove swiperefresh dependency 2023-03-12 22:40:26 +08:00
maxmoney21m 7630e07dc9 Replace deprecated SwipeRefresh with PullRefresh 2023-03-12 22:40:26 +08:00
maxmoney21m 5da70a0f95 Persist lazy list state on Home, Global and Chatrooms 2023-03-12 22:39:02 +08:00
maxmoney21m be19d0a9cf Add persistent lazy list state composable 2023-03-12 22:39:02 +08:00
maxmoney21m 20431a87ea Use List instead of Set to keep account order 2023-03-12 22:37:23 +08:00
maxmoney21m cc36dcffe0 Extract strings 2023-03-12 22:37:22 +08:00
maxmoney21m 32b50418de Indicate whether account has pubkey or privkey 2023-03-12 22:37:21 +08:00
maxmoney21m b1cb7d599f Make account switcher scrollable 2023-03-12 22:37:21 +08:00
maxmoney21m f34d79106d Fix account switching 2023-03-12 22:37:20 +08:00
maxmoney21m 269197a59d Migration from old to new preferences 2023-03-12 22:37:20 +08:00
maxmoney21m 992796a7bf Delete user-specific preference xml file on logout 2023-03-12 22:37:19 +08:00
maxmoney21m e472a494a1 Re-use existing LoginPage component in account switcher 2023-03-12 22:37:18 +08:00
maxmoney21m b40bde10a0 Add account, switch account, and logout flows 2023-03-12 22:37:17 +08:00
maxmoney21m 9561261bf4 Add note 2023-03-12 22:37:17 +08:00
maxmoney21m 3a2403b344 Use multiple preference files for different accounts 2023-03-12 22:37:16 +08:00
maxmoney21m b7f8241a08 Add account switch bottom sheet and update LocalPrefs 2023-03-12 22:37:15 +08:00
Vitor PamplonaandGitHub 907aa7b734 Merge pull request #256 from maxmoney21m/bugfix/note-quick-actions
Add quick action menu in chats and threads
2023-03-12 10:26:31 -04:00
Vitor PamplonaandGitHub 74336d72c0 Merge pull request #251 from h3y6e/patch-1
Correct identity claim for mastodon
2023-03-12 10:25:38 -04:00
Vitor PamplonaandGitHub 0b9b288146 Merge pull request #255 from maxmoney21m/bugfix/254
Fix string
2023-03-12 10:24:10 -04:00
Vitor PamplonaandGitHub b7deaaa1a6 Merge pull request #257 from akiomik/add-string-resources
Add string resources to strings.xml
2023-03-12 10:23:48 -04:00
Vitor PamplonaandGitHub 9dc9d7d947 Merge pull request #260 from Bardesss/patch-1
Fixed NL translation
2023-03-12 10:22:57 -04:00
Vitor PamplonaandGitHub a69a5b1a1e Merge pull request #261 from Chemaclass/refactor-LocalCache
Refactor LocalCache
2023-03-12 10:22:41 -04:00
Vitor PamplonaandGitHub 5842a84309 Merge pull request #262 from kappaseijin/fixed_var_name_for_NIP-05
Fixed a variable name for NIP-05
2023-03-12 10:19:23 -04:00
kappaseijin 332ddc15f7 Fixed a variable name for NIP-05 2023-03-12 14:43:33 +09:00
Chemaclass 30f4ff9d6f Remove indentation level LocalCache.consume(ChannelCreateEvent) 2023-03-11 20:43:46 +00:00
Chemaclass d166f22c58 Extract checkIfValidHex() 2023-03-11 20:34:44 +00:00
BardesssandGitHub bee361cea7 Update strings.xml
Fixed threads
2023-03-11 13:26:59 +00:00
Akiomi Kamakura 31e724662d Add string resources to strings.xml 2023-03-11 13:02:35 +09:00
maxmoney21m 6109131ef5 Add quick action menu in chats and threads 2023-03-11 11:21:16 +08:00
maxmoney21m fe9798fdfc Fix string 2023-03-11 10:50:37 +08:00
heyhoeandGitHub a8dc03c477 Correct identity claim for mastodon 2023-03-11 03:46:07 +09:00
maxmoney21m d4cee941ef Fix inverted QR scan 2023-03-11 01:22:23 +08:00
Vitor Pamplona 9adb376198 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-10 11:28:13 -05:00
Vitor Pamplona b387808a02 0.24.2 2023-03-10 10:46:02 -05:00
Vitor Pamplona 8eb37f9864 Using full banners on User Profile pages and Drawer
Fixes: https://github.com/vitorpamplona/amethyst/issues/247
2023-03-10 10:35:07 -05:00
Vitor Pamplona 5a45c6d25f Adds Reporting menu option to badge notifications 2023-03-10 10:34:27 -05:00
Vitor Pamplona 78261ccd94 Puts the ZapValue calculator inside a caching function. 2023-03-10 10:15:32 -05:00
Vitor Pamplona 57ee0ff1ff BugFix for dark text on dark background. 2023-03-10 10:14:54 -05:00
Vitor Pamplona a2d04c9908 Merge remote-tracking branch 'origin/HEAD' 2023-03-10 10:03:34 -05:00
Vitor Pamplona 1ecc4308c1 Merge remote-tracking branch 'origin/HEAD' 2023-03-10 10:02:13 -05:00
Vitor PamplonaandGitHub b5b6bd5499 Merge pull request #246 from greenart7c3/main
add missing portuguese translations
2023-03-10 09:59:48 -05:00
Vitor PamplonaandGitHub e1d1a6125f Merge pull request #248 from Bardesss/main
Add dutch translation
2023-03-10 09:59:22 -05:00
Vitor Pamplona ad8d9cabe2 Refreshing inner user lists faster (instead of waiting for LocalCache to update) 2023-03-10 09:58:58 -05:00
Vitor Pamplona 1d74bfb191 Refines visibility of refresh functions 2023-03-10 09:38:56 -05:00
Vitor Pamplona 3d877fda28 Moves Zap amount Calculations to IO Thread 2023-03-10 09:38:33 -05:00
Vitor Pamplona 88e03c1a0a Removes Account observer from ChatroomCompose 2023-03-10 09:37:43 -05:00
Vitor Pamplona b1de2bf326 Moves MarkAsRead write and read to IO context. 2023-03-10 09:37:17 -05:00
Vitor Pamplona ae1f0c5547 Starts websockets on an IO Thread 2023-03-10 09:35:50 -05:00
Vitor Pamplona b9304aa1b5 Removing ContactList Log 2023-03-10 09:35:25 -05:00
Vitor Pamplona 37b3da685d Updates Readme for last versions new features. 2023-03-10 09:35:06 -05:00
maxmoney21m ae6cf15768 Replace mlkit with zxing QR scanner 2023-03-10 22:14:38 +08:00
BardesssandGitHub 844fb4ce3d Create strings.xml
Dutch translation
2023-03-10 10:12:02 +00:00
BardesssandGitHub 4c495f0270 Delete values-nl 2023-03-10 10:11:37 +00:00
BardesssandGitHub 8530cf5afe strings.xml
Dutch translation
2023-03-10 10:09:20 +00:00
greenart7c3 0c8ddc5067 add missing portuguese translations 2023-03-10 06:06:59 -03:00
Vitor Pamplona 9cf7cc4e5d Merge remote-tracking branch 'origin/HEAD' 2023-03-09 18:56:18 -05:00
Vitor Pamplona 2704421fb3 Showing private message notifications in the notification tab 2023-03-09 18:55:57 -05:00
Vitor PamplonaandGitHub e68374525a Merge pull request #244 from Chemaclass/update-EncryptedStorage
Update EncryptedStorage
2023-03-09 17:15:53 -05:00
Chemaclass e7f814e205 Change EncryptedStorage from class to object 2023-03-09 21:03:21 +00:00
Chemaclass 7c94af2fb2 Use MasterKey.Builder instead of deprecated MasterKeys 2023-03-09 20:56:56 +00:00
Vitor Pamplona ccf2b06b10 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 15:42:22 -05:00
Vitor PamplonaandGitHub c511ad6f73 Merge pull request #243 from Chemaclass/add-missing-es-translations
Add missing ES translations
2023-03-09 15:03:48 -05:00
Chemaclass 25f8bde880 Remove more untranslatable strings 2023-03-09 19:55:41 +00:00
Chemaclass ab88629085 Add missing ES translations 2023-03-09 19:51:02 +00:00
Chemaclass 82c4e02cb1 Remove sats from all langs becuase its not translatable 2023-03-09 19:42:25 +00:00
Vitor PamplonaandGitHub 1b20f9d85c Merge pull request #242 from maxmoney21m/bugfix/239-select-text-scroll
Fix #239
2023-03-09 13:53:17 -05:00
Vitor Pamplona 1c9b702a06 Merge remote-tracking branch 'origin/HEAD' 2023-03-09 13:27:47 -05:00
maxmoney21m d41836ca19 Fix #239 2023-03-10 02:27:29 +08:00
Vitor PamplonaandGitHub 99d5b19acb Merge pull request #240 from maxmoney21m/feature/zhtw-translations
More translations
2023-03-09 13:25:49 -05:00
Vitor Pamplona 3b582636f4 NIP-39 Support 2023-03-09 13:24:32 -05:00
maxmoney21m 680c0f1089 Add tw translations 2023-03-10 00:28:15 +08:00
Vitor Pamplona 56433a3ad3 v0.24.1 2023-03-09 10:10:44 -05:00
Vitor Pamplona 2ebbac0f58 Showing pubkeys prominently in the Profile Page. 2023-03-09 10:08:11 -05:00
Vitor PamplonaandGitHub e616d2bdca Merge pull request #238 from miseelu/main
Added Chinese( Traditional ) Translation
2023-03-09 10:04:43 -05:00
Milu Lu da4b3e78fd Added Chinese( Traditional ) Translation 2023-03-09 22:45:58 +08:00
Milu Lu 8bff85749a Merge branch 'main' of https://github.com/miseelu/amethystcc
 Conflicts:
	app/src/main/res/values-zh-rHK/strings.xml
2023-03-09 22:36:47 +08:00
Milu Lu a577bf8e6a Added Chinese( Traditional ) Translation 2023-03-09 22:35:44 +08:00
Vitor Pamplona c8d19f142e Keeping Quick Action and DropDown similar for now. 2023-03-09 09:34:25 -05:00
miseeluandGitHub e3aa6b5d3c Merge branch 'vitorpamplona:main' into main 2023-03-09 22:33:56 +08:00
Vitor Pamplona 61d8a31a8a Merge remote-tracking branch 'origin/HEAD' 2023-03-09 09:26:59 -05:00
Milu Lu 91f92043b4 Added Chinese( Traditional ) Translation 2023-03-09 22:25:10 +08:00
Vitor PamplonaandGitHub bb635c43e9 Merge pull request #236 from miseelu/main
Added Chinese(Simplified) Translation
2023-03-09 09:20:27 -05:00
Vitor Pamplona 0cbe1b746a Tweaks colors of the Quick Action to work better in Dark Mode 2023-03-09 09:19:41 -05:00
Vitor Pamplona ddeead15ac Reduces Text Size of the Quick Action menu to fit on screen 2023-03-09 09:19:18 -05:00
Vitor Pamplona f9965bc7d1 Removes extra vertical bar in the QuickAction Menu 2023-03-09 09:18:46 -05:00
Milu Lu e820250e4e Added Chinese(Simplified) Translation 2023-03-09 22:14:13 +08:00
Vitor Pamplona f2f53c66bd Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 09:01:27 -05:00
Vitor Pamplona 39b53c05b7 Merge remote-tracking branch 'origin/HEAD' 2023-03-09 08:49:42 -05:00
Vitor Pamplona d148644799 Allows Rendering of PrivateMessages as Notes (for notifications) with correct redirection to the private room when clicked 2023-03-09 08:49:29 -05:00
Vitor Pamplona ceb0895b8d Using Log.debug instead of println for State Dump action 2023-03-09 08:48:11 -05:00
Vitor Pamplona 30e2070b89 Deleting awards cache. 2023-03-09 08:47:16 -05:00
Vitor PamplonaandGitHub 05446af068 Merge pull request #234 from Radiokot/fix/actualize_ru_uk_21_03_09
Actualize Russian and Ukrainian translations
2023-03-09 08:40:14 -05:00
Oleg Koretsky 0459c4f90a Actualize Russian and Ukrainian translations 2023-03-09 13:27:46 +02:00
Vitor Pamplona b93f28f09d Merge branch 'main' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
2023-03-08 17:47:34 -05:00
Vitor Pamplona 38df1ba6f7 Merge commit '40a6feab8a4a36813644c19ca034333b73e98132' into less_memory_test_branch 2023-03-08 17:35:30 -05:00
Vitor Pamplona 6c9aa9b6ff Merge commit '9c7a5b73b3a75a465f78c1d97726e66f0e3032a3' into less_memory_test_branch 2023-03-08 17:35:21 -05:00
Vitor Pamplona 26b581b13c Merge commit 'a41f2c38ec9683f0e9aaca3e4cffb2cd45f21145' into less_memory_test_branch 2023-03-08 17:34:40 -05:00
Vitor Pamplona 6a3cae1c8d Merge commit 'c7ba0bce5bbc3edd8ece9669751b110d59c40052' into less_memory_test_branch 2023-03-08 17:34:29 -05:00
Vitor Pamplona 40ac31983c Merge commit 'c9b859610e660f7e2d906594410bc7fbf2b1914a' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
2023-03-08 17:34:22 -05:00
Vitor Pamplona 8c25667d0f Organize imports to simplify merge 2023-03-08 17:29:36 -05:00
Vitor Pamplona 6acc842d4f Readding LocalCacheImport 2023-03-08 17:18:25 -05:00
Vitor Pamplona fc4b68b264 Merge commit 'f1b6927bb37d1c9887fb48aa5655a6d2df558e81' into less_memory_test_branch
# Conflicts:
#	app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt
2023-03-08 17:17:03 -05:00
Vitor Pamplona 9470560002 Testing Lint Commit 2023-03-08 17:07:56 -05:00
Vitor Pamplona cd950ee946 Merge commit '2d92e1d44491d62005e45adcf3dc1c5a403c386f' into less_memory_test_branch 2023-03-08 17:04:24 -05:00
Vitor Pamplona f9f68e2a4f Merge commit '691b8225035d8fe59539a971a85b1df2f2f84ebe' into less_memory_test_branch 2023-03-08 17:03:42 -05:00
Vitor PamplonaandGitHub 0986698c4b Merge pull request #230 from maxmoney21m/bugfix/223-invoice-intent
Fix LN invoice in expandable text component
2023-03-08 15:16:44 -05:00
Vitor PamplonaandGitHub 24b85ed9ac Merge pull request #231 from Chemaclass/add-pre-push-hook
Add pre-push git hook to run tests
2023-03-08 15:15:00 -05:00
Vitor PamplonaandGitHub 2199d98a06 Merge pull request #232 from Chemaclass/remove-deps.txt
Remove deps.txt
2023-03-08 15:14:03 -05:00
Chemaclass bef122e909 Remove deps.txt 2023-03-08 19:43:37 +00:00
Chemaclass ce70dd26be Add pre-push git hook to run tests 2023-03-08 19:17:04 +00:00
Vitor PamplonaandGitHub 19c7ab189c Merge pull request #221 from Radiokot/fix/looong_buttons
Fix looong buttons due to localization
2023-03-08 13:58:40 -05:00
maxmoney21m 4be15e23da Fix LN invoice in expandable text component #223 2023-03-09 02:57:17 +08:00
Oleg Koretsky c6a991967c Autoformat by ktLint 2023-03-08 20:53:06 +02:00
Oleg Koretsky f2b1fa9101 Fix shrinking of public channel names 2023-03-08 20:50:42 +02:00
Vitor PamplonaandGitHub 62497fd270 Merge pull request #228 from Chemaclass/fix-KeyParseTest
Fix KeyParseTest
2023-03-08 13:35:50 -05:00
Chemaclass ffed3e7e22 Fix KeyParseTest 2023-03-08 18:32:32 +00:00
Oleg Koretsky 13e488e1e2 Merge branch 'main' into fix/looong_buttons 2023-03-08 20:31:31 +02:00
Vitor PamplonaandGitHub b62adc61ab Merge pull request #226 from maxmoney21m/feature/71-note-menu
Redesign note long click menu, URL sharing
2023-03-08 13:15:13 -05:00
Vitor Pamplona 5055afd979 Fixes the build 2023-03-08 13:14:06 -05:00
maxmoney21m 9e58ef1110 Show alert first time deleting a note 2023-03-09 02:07:01 +08:00
maxmoney21m 07e4c82c1b Add (un)follow/delete to note quick actions 2023-03-09 02:07:00 +08:00
maxmoney21m 32cd5f7eef Fix linting errors 2023-03-09 02:06:58 +08:00
maxmoney21m 15771289e2 Revise note quick action button layout 2023-03-09 02:06:57 +08:00
maxmoney21m 4967361257 Note long click action opens NoteQuickActionMenu 2023-03-09 02:06:56 +08:00
maxmoney21m 6324dc64d5 Add NoteQuickActionMenu composable cf #71 2023-03-09 02:06:55 +08:00
Vitor Pamplona 71671865ff Merge remote-tracking branch 'origin/HEAD' 2023-03-08 11:47:29 -05:00
Vitor Pamplona 4d7f29ba8f Adds a parser for naddr when composing messages and a filter to avoid thinking the a tag is a reply instead of a quote. 2023-03-08 11:47:13 -05:00
Vitor PamplonaandGitHub 7b7d87a277 Merge pull request #227 from detherminal/main
Added Turkish Translation
2023-03-08 11:11:06 -05:00
detherminal 9c7a5b73b3 Added Turkish Translation 2023-03-08 18:45:07 +03:00
Vitor Pamplona 40a6feab8a Fixes Clickable tag for addresses (naddr) 2023-03-08 09:59:19 -05:00
Vitor PamplonaandGitHub a41f2c38ec Merge pull request #225 from Chemaclass/refactor-nip19-into-module
Refactor Nip19 into module
2023-03-08 09:49:37 -05:00
Chemaclass 26b5061042 Rename Utils to Tlv 2023-03-08 14:21:52 +00:00
Chemaclass 49199fe892 Change Nip19 from class to object 2023-03-08 14:21:52 +00:00
Chemaclass 30fe861853 Extract from Nip19 global funcs into another script 2023-03-08 14:21:52 +00:00
Chemaclass 1998711e60 Extract TlvTypes to nip19 module 2023-03-08 14:21:52 +00:00
Vitor PamplonaandGitHub 9ab1828634 Merge pull request #224 from Chemaclass/fix-hu-translations
Fix HU translations
2023-03-08 09:18:16 -05:00
Chemaclass c7ba0bce5b Fix HU translations 2023-03-08 14:14:58 +00:00
Vitor PamplonaandGitHub dffce92aa7 Merge pull request #220 from Chemaclass/add-ktlint
Add ktlint
2023-03-08 09:08:53 -05:00
Vitor PamplonaandGitHub 6f71685f08 Merge branch 'main' into add-ktlint 2023-03-08 09:08:46 -05:00
Vitor PamplonaandGitHub b763d3c140 Merge pull request #222 from ZsZolee/main
Hungarian Translation
2023-03-08 09:04:21 -05:00
Vitor PamplonaandGitHub 12b2957648 Merge pull request #219 from alltheseas/patch-1
Added Privacy on Relays to Readme.MD
2023-03-08 09:03:37 -05:00
Vitor Pamplona 3fd656da9f Removes several Indexes from User and Notes to see if we can reduce App's memory usage. 2023-03-08 08:47:33 -05:00
Vitor Pamplona 6543df5d28 Adds a Log to Failures in Parsing contained Zap events 2023-03-08 08:45:03 -05:00
Vitor Pamplona 67d6b24993 Removing unnecessary coroutine 2023-03-08 08:44:05 -05:00
Vitor Pamplona 9f5b3d82c5 Making LnInvoice amount a Lazy property. 2023-03-08 08:43:38 -05:00
Vitor Pamplona 7275799f9d Solves crashing when events have null tags. 2023-03-08 08:31:56 -05:00
Vitor Pamplona 77a70c3202 Duplicated Stop 2023-03-08 08:28:09 -05:00
ZoltanandGitHub 00f38670a5 Hungarian Translation
This time I made the proper structure for the file. (Thanks Jose Maria Valera Reales for the help!)
2023-03-08 09:58:24 +01:00
Oleg Koretsky 773d747f5a Fix slightly lighter color of the terms checkbox 2023-03-07 22:15:55 +02:00
Oleg Koretsky a82fc73346 Rearrange buttons on the new post screen. Add icons 2023-03-07 22:02:53 +02:00
alltheseasandGitHub 4a8ec97f2f Update README.md
Added section on information visibility, information permanence, and DM privacy.
2023-03-07 13:59:23 -06:00
Chemaclass c9b859610e Format last files with conflicts 2023-03-07 20:43:34 +01:00
Chemaclass f1b6927bb3 Merge branch 'main' into add-ktlint
Conflicts:
	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
	app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
	app/src/main/java/com/vitorpamplona/amethyst/model/ThreadAssembler.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/Nip19.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/ATag.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/ReactionEvent.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/ReportEvent.kt
	app/src/main/java/com/vitorpamplona/amethyst/service/model/RepostEvent.kt
	app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt
	app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
	app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt
2023-03-07 20:42:38 +01:00
Vitor Pamplona 9720fdfce9 Moving aTag check to the ATag class 2023-03-07 13:53:03 -05:00
Vitor Pamplona 1919dc5c81 1. Moves AddressableNote index from NAddr to aTag format due to the presence of a preferred relay inside NAddr, which is an optional field and should not be part of the idex.
2. Parses relay information for Addressable Notes
2023-03-07 13:48:05 -05:00
Chemaclass eff8dbbafa Run linter 2023-03-07 19:47:38 +01:00
Chemaclass 2d92e1d444 Add Linder to build CI 2023-03-07 19:45:50 +01:00
Chemaclass c4c8c0fbf7 Adding pre-commit git hook 2023-03-07 19:08:11 +01:00
alltheseasandGitHub f39c84d6a4 Update README.md
added additional information on relays & privacy
2023-03-07 12:06:52 -06:00
Chemaclass f8d6cf8dba Add Linting section to README 2023-03-07 18:59:52 +01:00
Chemaclass 691b822503 Install ktlint plugin 2023-03-07 18:58:13 +01:00
alltheseasandGitHub d9d05bf6ba Added Privacy on Relays to Readme.MD
Added a small section on user privacy implications on Amethyst & relays use.
2023-03-07 10:35:54 -06:00
Vitor PamplonaandGitHub 5add9669f2 Merge pull request #208 from maxmoney21m/bugfix/204-fingerprint-crash-android9
Biometric support down to app's minimum SDK level 26, fixes #204
2023-03-06 13:10:31 -05:00
Vitor PamplonaandGitHub a1751cc831 Merge pull request #213 from Chemaclass/fix-local-cache
Fix failing building due to an error on LocalCache
2023-03-06 13:10:11 -05:00
Chemaclass f22a1ddf47 Use event.id() 2023-03-06 18:43:23 +01:00
Vitor PamplonaandGitHub 9fb8bae349 Merge pull request #205 from Chemaclass/refactor-UserProfileZapsFeedFilter
Refactor UserProfileZapsFeedFilter
2023-03-06 12:33:48 -05:00
Vitor PamplonaandGitHub 17b9280660 Merge pull request #210 from bkBrunobk/patch-2
Update strings.xml
2023-03-06 12:27:49 -05:00
Oleg Koretsky b8bc076240 Shorten 'post' ru and uk translation 2023-03-06 18:10:51 +02:00
Oleg Koretsky 2a82b9e6ca Fix word wrapping in terms checkbox on the login screen 2023-03-06 18:09:41 +02:00
BackTo2009andGitHub 95905e6c95 Update strings.xml 2023-03-06 13:19:21 +01:00
d0bcb4f4c2 <string name="about_us">"à propos"</string>Update app/src/main/res/values-fr/strings.xml
Co-authored-by: Jose Maria Valera Reales <chemaclass@outlook.es>
2023-03-06 12:42:05 +01:00
BackTo2009andGitHub 829121eb6a Update strings.xml
v0.24.0 translations tuned
2023-03-06 11:28:32 +01:00
maxmoney21m 1b0453cbc4 Biometric support down to app min SDK (level 26), fixes #204 2023-03-06 14:15:50 +08:00
Vitor Pamplona 43e8106aa9 0.24.0 2023-03-05 18:34:52 -05:00
Vitor Pamplona e86ae4ac41 Read only support for Badges. 2023-03-05 18:34:11 -05:00
Chemaclass 1424267628 Rename UserZaps.forProfileFeed() 2023-03-05 23:56:11 +01:00
Chemaclass 6aa7c229b0 create failing test aggregate_zap_amount_group_by_user 2023-03-05 23:30:24 +01:00
Chemaclass 53e520ee8a Add test avoid duplicates with same zap request 2023-03-05 23:28:39 +01:00
Chemaclass f18ea8cf76 Create LnZapEventInterface 2023-03-05 23:00:19 +01:00
Chemaclass b8937594bc Create EventInterface 2023-03-05 22:42:19 +01:00
Vitor Pamplona 12570d3e26 Fixing for https://github.com/vitorpamplona/amethyst/pull/201#pullrequestreview-1325255540 2023-03-05 13:02:26 -05:00
Vitor PamplonaandGitHub 8fe2c895de Merge pull request #202 from maxmoney21m/feature/134-full-size-pfp
Allow tapping on PFP and banner to open zoom dialog
2023-03-05 12:57:08 -05:00
Vitor Pamplona f251d56b1a Merge remote-tracking branch 'origin/HEAD' 2023-03-05 12:43:57 -05:00
maxmoney21m e5518e23a8 Small code cleanups 2023-03-06 01:33:16 +08:00
maxmoney21m d6f3e9540f Allow zooming on profile picture and banner #134 2023-03-06 01:32:53 +08:00
maxmoney21m 79e7bb2931 Extract ZoomableImageDialog for reuse 2023-03-06 01:39:42 +08:00
Vitor PamplonaandGitHub 3e3d683234 Merge pull request #201 from Chemaclass/refactor-ui-dal
Refactor ui/dal/*Filters
2023-03-05 12:18:32 -05:00
Chemaclass 429f54848a Refactor ui/dal Filters
Focus on normalising the code-style to simplify the readability
2023-03-05 18:08:49 +01:00
Vitor Pamplona 4085285f90 Fixes showing the R.string.first id instead of text. 2023-03-05 09:06:54 -05:00
Vitor Pamplona b3d219b2de reverting order in the search screen. 2023-03-04 22:49:49 -05:00
Vitor Pamplona 797eda2ae2 v0.23.1 2023-03-04 20:56:41 -05:00
Vitor Pamplona 2478fdbcce Fixes crash in Image upload 2023-03-04 20:55:52 -05:00
Vitor Pamplona d7cf198134 v0.23.0 2023-03-04 19:12:48 -05:00
Vitor Pamplona 681e3eb44e Support for NIP-50: Search 2023-03-04 19:11:49 -05:00
Vitor Pamplona 5cc851ecc8 Adds some spacing in the private key export screen. 2023-03-04 17:38:04 -05:00
Vitor Pamplona 2a0426b845 Adds notes to decide between Gson and Jackson in the future. 2023-03-04 17:37:38 -05:00
Vitor Pamplona 567cf83b92 Centralized Mark All as Read button 2023-03-04 17:31:46 -05:00
Vitor PamplonaandGitHub c7debb272d Merge pull request #177 from maxmoney21m/feature/172-mark-all-read
Fix #172, add "Mark all as read" feature for DMs and chat rooms
2023-03-04 17:12:53 -05:00
Vitor PamplonaandGitHub cf7464b5b6 Merge pull request #191 from maxmoney21m/feature/biometric-backup-nsec
Require biometric with device lock fallback to copy nsec
2023-03-04 17:11:35 -05:00
Vitor PamplonaandGitHub 9cb3406d7d Merge pull request #195 from Radiokot/feature/login_autofill
Add autofill support to the login screen
2023-03-04 17:10:37 -05:00
Vitor PamplonaandGitHub 3613fce68d Merge pull request #194 from maxmoney21m/bugfix/157-nip05-profile-underscore
Bugfix/157 nip05 profile underscore
2023-03-04 17:10:21 -05:00
Vitor PamplonaandGitHub 1b62574eca Merge pull request #193 from bkBrunobk/patch-1
French translation for Amethyst ;)
2023-03-04 17:09:47 -05:00
Vitor PamplonaandGitHub fbd5a3296c Merge pull request #192 from maxmoney21m/bugfix/global-bottom-icon
Change magnifying glass to globe for global/search tab on AppBottomBar
2023-03-04 17:09:31 -05:00
Vitor PamplonaandGitHub c5e61400b7 Merge pull request #189 from Chemaclass/refactor-lowercase
Use lowercase() instead of toLowerCase() [deprecated]
2023-03-04 17:09:02 -05:00
Vitor Pamplona 42dd900491 BugFix: Unverifiable Events with Unicode 2028 and 2029 characters. 2023-03-04 17:03:43 -05:00
Oleg Koretsky 295f1f53cd Add autofill support to the login screen 2023-03-04 21:36:00 +02:00
maxmoney21m 0b2b2d58ab Reindent with 4 spaces 2023-03-05 01:56:42 +08:00
maxmoney21m c1637de63a Remove leading underscore from profile page #157 2023-03-05 01:55:59 +08:00
BackTo2009andGitHub 566b44f4ef French translation for Amethyst ;) 2023-03-04 18:42:16 +01:00
maxmoney21m 6275051857 Change magnifying glass to globe #183 2023-03-05 01:30:55 +08:00
Vitor Pamplona 58e0aa5f8f Fixing relative paths on OG:IMAGE tags to correctly see a preview card 2023-03-04 10:49:36 -05:00
maxmoney21m d6b5af6d77 Require biometric with device lock fallback to copy nsec 2023-03-04 22:26:06 +08:00
Vitor Pamplona 9432135847 Moving spam checks to be after the check if already processed events. 2023-03-04 08:23:12 -05:00
Vitor Pamplona e7be1cc8e7 Speeding up findCitations algorithm 2023-03-04 08:04:14 -05:00
Vitor Pamplona c0bdb40c7d Faster way to count reports. 2023-03-04 07:12:42 -05:00
Chemaclass bb59f68253 Use lowercase() instead of toLowerCase() [deprecated] 2023-03-04 11:27:27 +01:00
maxmoney21m 8961675a3e Fix string resource use from earlier PR (already merged) 2023-03-04 15:18:07 +08:00
maxmoney21m 3dd5d5c4b2 Add LaunchedEffect key to fix pre-existing bug 2023-03-04 15:03:46 +08:00
maxmoney21m bd8e9da966 Swap LaunchedEffect key for clarity 2023-03-04 15:03:11 +08:00
maxmoney21m 6067a7d890 Use new channel() method 2023-03-04 14:33:09 +08:00
maxmoney21mandGitHub 42a27a6d8d Merge branch 'main' into feature/172-mark-all-read 2023-03-04 14:17:31 +08:00
Vitor Pamplona 308a79a305 0.22.3 2023-03-03 19:43:44 -05:00
Vitor Pamplona 5e5c393472 Bringing back the idea of checking for change before updating the screen. The check happens between 500us and 1ms and the screen update generally in 10-100ms 2023-03-03 19:40:26 -05:00
Vitor Pamplona ea905ef6ed Removes the element that was forcing everything to be right aligned 2023-03-03 18:14:51 -05:00
Vitor Pamplona 5f98a54452 Reverting change to activate RTL given some characters, it was picking up some english chars 2023-03-03 18:11:50 -05:00
Vitor Pamplona 8627fd4b4c Delete Origin Header (snort doesn't need it anymore) 2023-03-03 18:08:09 -05:00
Vitor PamplonaandGitHub 2a65b7241a Merge pull request #186 from rashedswen/main
temporary solution for mirroring arabic text
2023-03-03 18:06:46 -05:00
Vitor PamplonaandGitHub 7863961aba Merge branch 'main' into main 2023-03-03 18:06:40 -05:00
Vitor PamplonaandGitHub a84be7fc61 Merge pull request #187 from Chemaclass/refactor-Nip19
Refactor Nip19
2023-03-03 18:03:58 -05:00
Chemaclass 657f99a65a Remove non-used imports in Nip19 2023-03-03 23:40:09 +01:00
Chemaclass 91591abd14 Extract method refactoring in Nip19::uriToRoute() 2023-03-03 23:36:16 +01:00
Chemaclass fd58da2a93 Remove 1 indentation level from uriToRoute() 2023-03-03 23:26:35 +01:00
Chemaclass b35a59372c Prepare move unit tests for uri_to_route behaviour 2023-03-03 23:23:42 +01:00
Chemaclass c1113f9df9 Add test uri_to_route_npub 2023-03-03 23:19:05 +01:00
Chemaclass bd3d7e1aa3 Prepare test parse_TLV 2023-03-03 22:58:24 +01:00
Chemaclass 47f3fe5cc6 refactor Nip19Test introduce byteArrayOfInts() 2023-03-03 22:46:15 +01:00
Chemaclass b6e16ad470 Create Nip19Test testing toInt32() 2023-03-03 22:40:46 +01:00
Vitor Pamplona b469915905 v0.22.2 2023-03-03 16:12:11 -05:00
Vitor PamplonaandGitHub d3b557b778 Merge pull request #182 from maxmoney21m/feature/backup-keys
Move nsec backup to drawer and organize drawer
2023-03-03 16:08:42 -05:00
Vitor PamplonaandGitHub 1154f35c77 Merge pull request #185 from Chemaclass/fix-typos-es
Fix some typos in ES translations
2023-03-03 16:06:10 -05:00
Vitor Pamplona 5ae552117d Migrates Events to the App's source code as opposed to NostrPostr:
- Changes them to remove all secondary fields and turn them into functions
- Changes them to from being based in ByteArrays to String (since we use Hex everywhere and strings are immutable, we avoid duplicating memory with ByteArrays)
2023-03-03 16:00:47 -05:00
Rashed fe80b509e3 temporary solution for mirroring arabic text 2023-03-03 23:44:39 +03:00
Chemaclass f3bc190b0b Use boost=impulsar 2023-03-03 21:42:40 +01:00
Chemaclass 3674f6b354 Fix some typos in ES translations 2023-03-03 21:25:16 +01:00
maxmoney21m 3e32a91e34 Move nsec backup to Drawer and Dialog, organize Drawer 2023-03-04 03:02:04 +08:00
Vitor PamplonaandGitHub da9027b430 Merge pull request #181 from Chemaclass/add-es-lang
Add ES translations
2023-03-03 13:31:54 -05:00
Chemaclass 10d6011009 Fix typos suggested by JesusValera 2023-03-03 19:30:50 +01:00
Chemaclass 62d22bf4ca Add string translations for ES 2023-03-03 19:04:10 +01:00
Vitor Pamplona d05d0f0829 Fixing test cases 2023-03-03 12:00:27 -05:00
Vitor Pamplona 0aaf1f9f34 v0.22.1 2023-03-03 11:55:25 -05:00
Vitor Pamplona 756d9b17f7 Merge remote-tracking branch 'origin/HEAD' 2023-03-03 11:36:08 -05:00
Vitor PamplonaandGitHub f52bf26520 Merge pull request #180 from rashedswen/main
add follow directly from a note feature
2023-03-03 11:35:54 -05:00
Vitor Pamplona f4d5785710 Support for Replaceable Events (NIP-33) 2023-03-03 11:35:29 -05:00
maxmoney21m 75901d83a8 reindent LocalPreferences with 4 spaces 2023-03-03 21:17:27 +08:00
maxmoney21m f34dea868d extract LocalPreferences preference keys 2023-03-03 21:17:04 +08:00
maxmoney21m e07400409f set clearEncryptedStorage to remove all keys including lastRead timestamps 2023-03-03 21:12:15 +08:00
Rashed 8e858996fe hide follow button when the user already followed 2023-03-03 10:13:15 +03:00
Rashed f1711719f4 add follow directly from a note feature 2023-03-03 09:53:56 +03:00
maxmoney21m 8d7897601b Add buttons to mark DMs and chatrooms as read #172 2023-03-03 12:35:05 +08:00
Vitor PamplonaandGitHub 83f46f1a66 Merge pull request #176 from Radiokot/feature/uk_ru_translation
Add Russian and Ukrainian languages
2023-03-02 17:11:45 -05:00
Oleg Koretsky 88ceb436ac Fix typos 2023-03-02 23:30:37 +02:00
Oleg Koretsky 3cd37b8658 Add Ukrainian (uk) translation 2023-03-02 23:28:31 +02:00
Vitor Pamplona 04e099a212 Merge remote-tracking branch 'origin/HEAD' 2023-03-02 16:14:17 -05:00
Vitor Pamplona c0bcb638c0 Bringing TextNoteEvent from NostrPostr to Amethyst's codebase and Restructuring Addressable Notes. 2023-03-02 16:14:06 -05:00
Oleg Koretsky 0369b45385 Add Russian (ru) translation 2023-03-02 20:41:22 +02:00
Vitor Pamplona bab314ebc9 Fixing Preview Error when the url is invalid 2023-03-02 13:38:05 -05:00
Vitor PamplonaandGitHub 5a127706d7 Merge pull request #173 from marioaugustorama/main
Corrected translation!
2023-03-02 13:27:35 -05:00
Mário NunesandGitHub 4f9b1ab72c Corrected translation!
Changed from Mostar  to Mostrar
2023-03-02 14:40:44 -03:00
Vitor Pamplona 65d4d59e77 Trying move the cursor to the first item when the list updates immediately after being first drawn 2023-03-02 09:35:43 -05:00
Vitor Pamplona b95c013714 Restructuring the invalidateFeed events to happen quicker when users click the navigation row or when the app comes back from sleep. 2023-03-02 09:34:42 -05:00
Vitor Pamplona 10ce5a984e Simplifying logs 2023-03-02 09:33:19 -05:00
Vitor Pamplona 3521497cf8 Activating the loading of long text form across the app 2023-03-02 08:51:59 -05:00
Vitor Pamplona 0da542fede 0.22.0 2023-03-01 19:18:30 -05:00
Vitor Pamplona d883cc32f5 Early support for Markdown and Long Form Text 2023-03-01 19:18:07 -05:00
Vitor PamplonaandGitHub 64debb79fc Merge pull request #171 from Gnivom/login_safe_nsec_entry
Use KeyboardType.Password for login text field
2023-03-01 16:36:55 -05:00
Vitor PamplonaandGitHub 4ee6759957 Merge pull request #170 from rashedswen/patch-2
Update strings.xml
2023-03-01 16:36:16 -05:00
Vitor Pamplona 723db61e8d v0.21.2 2023-03-01 16:30:35 -05:00
Vitor Pamplona cb39281268 Adjustments to strings 2023-03-01 16:27:20 -05:00
Vitor Pamplona baa1b3f69f Using accountViewModel as a better way to request refreshes. 2023-03-01 16:22:22 -05:00
Gnivom c7bf4240dd Use KeyboardType.Password for login text field 2023-03-01 22:21:19 +01:00
Vitor Pamplona fb7edd1797 Hides hidden users from main feeds. 2023-03-01 16:15:49 -05:00
RashedandGitHub 4448e38100 Update strings.xml 2023-03-02 00:13:38 +03:00
Vitor Pamplona 716ebd01df Decreasing the speed of the updates of the feed. 2023-03-01 16:07:10 -05:00
Vitor Pamplona 58521b27de Fix for a String.format crashing the original library by using the Locale's default number format. 2023-03-01 15:48:18 -05:00
Vitor Pamplona f4db64fe7f 0.20.1 2023-03-01 14:00:39 -05:00
Vitor Pamplona d704352f1f Notifications now require the Notified to be the author of the post the notification is mentioning. 2023-03-01 14:00:03 -05:00
Vitor Pamplona 511cc86025 v0.21.0 2023-03-01 13:32:45 -05:00
Vitor Pamplona 58e8e0591b Keeping on Java11 to align with Android's capabilities. 2023-03-01 13:32:33 -05:00
Vitor Pamplona 57fc149051 Merge remote-tracking branch 'origin/HEAD' 2023-03-01 13:30:14 -05:00
Vitor Pamplona 55cc780c08 Adding the ability to click and hold to copy the image url on profile. 2023-03-01 13:29:58 -05:00
Vitor PamplonaandGitHub 01a7ef3785 Merge pull request #168 from greenart7c3/main
Translated to brazilian portuguese
2023-03-01 13:28:19 -05:00
greenart7c3 b77e1cf960 Translated to brazilian portuguese 2023-03-01 14:47:41 -03:00
Vitor Pamplona c6eb5a7282 Fixes image resizes to 200x200 pixels to help with caching capabilities. 2023-03-01 11:58:37 -05:00
Vitor Pamplona bf48428fe3 Adds an Origin tag to be able to connect with Snort's relay. 2023-03-01 11:57:20 -05:00
Vitor Pamplona f4b366c4bd Fixing merge issue on reported Types 2023-03-01 10:07:59 -05:00
Vitor Pamplona 51ce424352 Disables the forced Locale to English due to i18n for Arabic 2023-03-01 10:04:08 -05:00
Vitor Pamplona 01b8b2a5fa Merge remote-tracking branch 'origin/HEAD'
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt
2023-03-01 10:02:41 -05:00
Vitor Pamplona 459e503c8a Avoids flickering the screen when the update from Loading to Loaded happens on cached objects. 2023-03-01 09:34:14 -05:00
Vitor PamplonaandGitHub 708f6b2e75 Merge pull request #166 from rashedswen/main
extract strings and add Arabic Language
2023-03-01 09:03:41 -05:00
RashedandGitHub b7b7f4df39 Merge branch 'main' into main 2023-03-01 09:16:59 +02:00
Rashed 855b2ff5b1 resolve conflicts 2023-03-01 10:10:27 +03:00
Rashed 67e6349877 resolve conflicts but new issue found when changing the language 2023-03-01 07:54:27 +03:00
Rashed 5eb3b2062c Update strings.xml 2023-03-01 07:50:01 +03:00
Rashed c649d0820c Update strings.xml
update just wrong translate
2023-03-01 07:50:01 +03:00
Rashed 07fd8fc5ac extract most strings to strings.xml and add arabic language to strings.xml file 2023-03-01 07:50:01 +03:00
Rashed e6afa7fb9a extract most strings to strings.xml and add arabic language to strings.xml file 2023-03-01 07:50:01 +03:00
Vitor Pamplona e0596dc3b9 New Version for PlayStore 2023-02-28 21:24:40 -05:00
Vitor Pamplona 381db713ab 0.20.4 2023-02-28 20:13:36 -05:00
Vitor Pamplona b6f5cc9ae5 Activating the Atomic Boolean instead of Synchronized sections 2023-02-28 20:10:52 -05:00
Vitor Pamplona df43e730ca Migrating the NostrDataSource to Atomic Boolean 2023-02-28 19:52:16 -05:00
Vitor Pamplona 95423bdd94 No need for account in search 2023-02-28 19:50:45 -05:00
Vitor Pamplona a18479e721 Reducing the need to start/stop home and chatroom lists to see if performance increases 2023-02-28 18:24:28 -05:00
Vitor Pamplona f68f32f6e5 Decrypting notes since Notifications can also show Private Message Likes 2023-02-28 16:51:35 -05:00
Vitor Pamplona ddc1e9fd69 Reverts to show all replies in Notifications. 2023-02-28 15:35:41 -05:00
Vitor Pamplona 3f99b602a7 Fixing spacing of the multicard notification 2023-02-28 15:27:28 -05:00
Vitor Pamplona b09577f816 Updating reports to the latest spec 2023-02-28 15:24:23 -05:00
Vitor Pamplona 3780ed001a Refining template texts 2023-02-28 11:37:39 -05:00
Vitor PamplonaandGitHub f8e49df126 Update issue templates 2023-02-28 11:34:12 -05:00
Vitor Pamplona 55e31d445e Simplifying relay set feature 2023-02-28 11:02:05 -05:00
Vitor Pamplona 5300d7bbe4 Review of Feature List 2023-02-28 11:01:24 -05:00
Vitor Pamplona 77fbb6f4f6 v0.20.3 2023-02-27 21:13:46 -05:00
Vitor Pamplona b570d80ac4 adding a delay to the search screen update on start up 2023-02-27 21:04:51 -05:00
Vitor Pamplona a7e17c810d Merging all notification reactions in one card. 2023-02-27 21:04:29 -05:00
Vitor Pamplona 256de32b7c Shorter Blank and Hidden notes. 2023-02-27 19:17:09 -05:00
Vitor Pamplona 554edb8bb9 v0.20.1 2023-02-27 17:18:54 -05:00
Vitor Pamplona bb3d8e0041 Filtering Notifications to the events that cite the user directly. 2023-02-27 17:14:37 -05:00
Vitor Pamplona 254ed99625 Unpacking replies in Quotes. 2023-02-27 17:14:15 -05:00
Vitor Pamplona 4c916c7957 Marks as read regardless of note.event status. 2023-02-27 15:19:24 -05:00
Vitor Pamplona 7e3cfcdcaf ThreadView avoids double loading. 2023-02-27 15:06:20 -05:00
Vitor Pamplona 528efacf38 Clearly marks the clicked item on ThreadView 2023-02-27 15:06:03 -05:00
Vitor Pamplona 5dac1899fe Allows users to delete usernames 2023-02-27 14:11:54 -05:00
Vitor Pamplona 6bff898fe4 Moves lnurl and nip05 to the services package. 2023-02-27 13:57:43 -05:00
Vitor Pamplona f065777df2 Only shows translations if the result is different than the origin 2023-02-27 13:54:17 -05:00
Vitor Pamplona 36d865510c adds nip05 to Chatroom headers. 2023-02-27 13:48:26 -05:00
Vitor Pamplona 6558627eef Adding time of the boost and menu options for the boosted event. 2023-02-27 13:41:23 -05:00
Vitor Pamplona f518969875 Moves Preview and NIP05 verification to IO Threads 2023-02-27 13:39:55 -05:00
Vitor Pamplona 1a7ecca89a Simple String Refactoring 2023-02-27 13:39:16 -05:00
Vitor Pamplona 26da2f514e v0.20.1 2023-02-27 11:30:11 -05:00
Vitor Pamplona 4c0651a6db Organize imports 2023-02-27 11:28:54 -05:00
Vitor Pamplona 59fce55822 Extracting NIP05 Composites in its own file 2023-02-27 11:24:21 -05:00
Vitor Pamplona c88425e737 changing package names 2023-02-27 10:52:39 -05:00
Vitor Pamplona d0dc44f3bd 1. Removing _ from the display
2. Checks if NIP 05 is an email before starting to verify to avoid crashes.
3. Allowing ellipsis to avoid breaking the nip05 into multiple lines.
2023-02-27 10:51:50 -05:00
Vitor Pamplona 25cb084a95 Adds NIP05 to the main note in a thread 2023-02-27 10:47:29 -05:00
Vitor Pamplona 0b2d9d6d18 BugFix for invalid nip05 domain names crashing the app. 2023-02-27 10:46:51 -05:00
Vitor Pamplona 145428c8ad v0.20.0 2023-02-26 19:22:33 -05:00
Vitor Pamplona 0cebb53791 Nip05 Support 2023-02-26 19:22:22 -05:00
Vitor Pamplona 8e6a4921a6 v0.19.1 2023-02-26 16:56:55 -05:00
Vitor Pamplona b806e71396 Crops replies to 3, follows first. 2023-02-26 16:56:12 -05:00
Vitor Pamplona 08f17bca27 Hides bottom navigation when keyboard is visible 2023-02-26 16:55:08 -05:00
Vitor Pamplona 14ec001601 Updating Readme 2023-02-26 15:58:58 -05:00
Vitor Pamplona dc763088f3 v0.19.0 2023-02-26 12:03:30 -05:00
Vitor Pamplona c087c5017c NIP-09 Event Deletion support: Works with TextNotes, Likes, Boosts and Reports. 2023-02-26 12:02:07 -05:00
Vitor Pamplona 2eff0626ec v0.18.3 2023-02-25 19:49:31 -05:00
Vitor Pamplona afc43ccd28 Animating chat reply appearance. 2023-02-25 19:39:03 -05:00
Vitor Pamplona 81f9edf00c Making Toast work to display error messages when Zaps fail. 2023-02-25 19:33:04 -05:00
Vitor Pamplona bdf2896df9 v0.18.2 2023-02-25 18:16:22 -05:00
Vitor Pamplona 8a2a1887f2 Improvements to ShowMore gradient and size of bubbles in chat. 2023-02-25 18:00:18 -05:00
Vitor Pamplona 26c8808a6b Updating dependencies 2023-02-25 16:18:31 -05:00
Vitor Pamplona 3db1017088 Fixes Ugly Tab Background color in the light theme 2023-02-25 16:15:36 -05:00
Vitor Pamplona 9178135127 Removes the block on Multiple Zaps in a single post (copy paste from the like button, that has a block to avoid multiple likes) 2023-02-25 16:11:03 -05:00
Vitor Pamplona 08b0d8032b Fixes liveSet to delete. | Solves the outofsync error chat has been reporting. 2023-02-25 15:28:42 -05:00
Vitor Pamplona a8e9189fce Synchronizes Client.connect to avoid random number in relay status 2023-02-25 15:28:07 -05:00
Vitor Pamplona efbaa0f434 Fixes the URL detector for 2 special characters in Chinese "," and "。" 2023-02-25 15:27:33 -05:00
Vitor Pamplona 0b4d11d5e6 Reverts fixing the English language to avoid issues with translation (now it only translates to english, instead of the local language) and also prepare for i18n work for other languages. 2023-02-25 15:26:53 -05:00
Vitor Pamplona 661cbab4ee v0.18.1 2023-02-24 17:39:58 -05:00
Vitor Pamplona ba21ae47ae correctly deletes a slave note 2023-02-24 17:39:34 -05:00
Vitor Pamplona 5e066e2485 Updates observers when deleting notes 2023-02-24 17:39:14 -05:00
Vitor Pamplona 3ebcfa111c Improved timing log 2023-02-24 17:24:16 -05:00
Vitor Pamplona c4046c0a88 Home doesn't need isAcceptable filter since all posts are coming from follows. Much faster. 2023-02-24 17:24:09 -05:00
Vitor Pamplona 3cdc77a277 BugFix Loading one thread after another crashed the app 2023-02-24 17:23:17 -05:00
Vitor Pamplona 951a4be615 Spam count per relay. 2023-02-24 16:36:02 -05:00
Vitor Pamplona 3e7d2a7b68 Removing PlayServices tag to import qrcode model 2023-02-24 16:03:12 -05:00
Vitor Pamplona 19854e2295 Using language identification model bundled into the app 2023-02-24 16:02:54 -05:00
Vitor Pamplona 21b10977d6 Moving barcode scanning model to ship with the app. 2023-02-24 16:02:22 -05:00
Vitor Pamplona f2ffa4c915 v0.18.0 2023-02-24 13:13:06 -05:00
Vitor Pamplona bc50f08ca2 Zaps, Likes and Replies in Public Chats and Private Messages 2023-02-24 13:12:31 -05:00
Vitor Pamplona 236177c6ce Fixes bug when note is not included in the returning set. 2023-02-24 12:08:56 -05:00
Vitor Pamplona b1bdd20255 Makes sure the note is always added in the threadView 2023-02-24 10:08:35 -05:00
Vitor Pamplona de26417391 Fixing https://github.com/vitorpamplona/amethyst/issues/149 2023-02-24 10:08:21 -05:00
Vitor PamplonaandGitHub f7a02c75da Merge pull request #152 from alaznem/patch-1
Update README.md by modifying current features list, checkmarked Zaps
2023-02-24 10:07:22 -05:00
Alazne MoralesandGitHub 2b16472160 Modified current features list, checkmarked Zaps 2023-02-24 12:51:46 +00:00
Vitor Pamplona 2e1b141e38 Showing zap amounts with correct scale. 2023-02-23 21:47:12 -05:00
Vitor Pamplona 7acb735151 v0.17.11 2023-02-23 21:40:29 -05:00
Vitor Pamplona 4fc4126740 Fixes: https://github.com/vitorpamplona/amethyst/issues/126 2023-02-23 21:39:43 -05:00
Vitor Pamplona 04dec3073a Fixes: https://github.com/vitorpamplona/amethyst/issues/151 2023-02-23 21:39:21 -05:00
Vitor Pamplona 40f1ef00a4 Saves Preferred language between each language pair 2023-02-23 21:39:07 -05:00
Vitor Pamplona d3504129b4 v0.17.10 2023-02-23 18:51:26 -05:00
Vitor Pamplona 3ba6f84a43 Avoids changing the position of the the list when the list updates. 2023-02-23 18:50:48 -05:00
Vitor Pamplona a399aa0ddd New Zap Amount Customization screen 2023-02-23 18:45:26 -05:00
Vitor Pamplona b1a3c2542f Marking the selected post in the thread view 2023-02-23 18:45:00 -05:00
Vitor Pamplona 866e31e75b Merge remote-tracking branch 'origin/HEAD' 2023-02-23 17:21:32 -05:00
Vitor PamplonaandGitHub 2a316de002 Merge pull request #150 from Radiokot/fix/i18n_issues
Fix system-localized strings
2023-02-23 17:21:14 -05:00
Oleg Koretsky 37da339683 Set English locale for the app to avoid seeing system-localized strings, like post time ago 2023-02-23 23:45:23 +02:00
Oleg Koretsky d35dd18072 Strip non-english resources 2023-02-23 23:21:01 +02:00
Vitor Pamplona c12ec88e96 Moves the # of downloaded msgs for channel index to 100 in the hopes that none of the 100 are being filtered by spam. 2023-02-23 14:45:39 -05:00
Vitor Pamplona f455898ac4 Properly removes a note from the database 2023-02-23 14:45:06 -05:00
Vitor Pamplona f11a33f981 Fixes Channel message deletion bug. 2023-02-23 14:44:40 -05:00
Vitor Pamplona 34417f5c33 Uses a Name to make the translation layer skip ln addresses 2023-02-23 14:44:20 -05:00
Vitor Pamplona 60af6dcd0c v0.17.9 2023-02-22 18:17:01 -05:00
Vitor Pamplona 648a1c7ec1 Adding LnZapRequest to the event observer as well. 2023-02-22 18:13:57 -05:00
Vitor Pamplona 6da089d864 Increase the amount of public channel messages to 10 in the Chatroom List View (to calculate the last one) 2023-02-22 18:13:38 -05:00
Vitor Pamplona ccd1bdcad0 Calling refresh observers even if there is no observers. 2023-02-22 18:12:46 -05:00
Vitor Pamplona e4b94e2fea Updating all user's relays when new ContactLists arrive 2023-02-22 18:12:21 -05:00
Vitor Pamplona bf3847eeaa Putting Follow and Unfollow Buttons into coroutines. 2023-02-22 18:11:46 -05:00
Vitor Pamplona e4129276a1 Fixing the Relay view in Profile 2023-02-22 18:10:56 -05:00
Vitor Pamplona dbd2f308c2 Removing the check to see if the feed is different before sending it to the UI. 2023-02-22 18:09:03 -05:00
Vitor Pamplona 61920ed31a Recording duplicated event IDs to avoid marking as spam just because the same event was downloaded from multiple relays. 2023-02-22 18:08:14 -05:00
Vitor Pamplona 8b9b6061b9 The live follows is held at the User not at the Account. 2023-02-22 18:07:01 -05:00
Vitor Pamplona 83f785c5c4 This idea of caching the user was causing issues. 2023-02-22 18:03:49 -05:00
Vitor Pamplona 639e3a5087 Blocks users from Reporting themselves 2023-02-22 16:17:56 -05:00
Vitor Pamplona bf83f686d7 v0.17.8 2023-02-22 15:23:11 -05:00
Vitor Pamplona bce30ce5de Trying to solve Notifications crash 2023-02-22 15:06:56 -05:00
Vitor Pamplona da949fa9fd Avoids breaking tags and image/previews in translation 2023-02-22 14:36:10 -05:00
Vitor PamplonaandGitHub da877d5f79 Merge pull request #147 from Radiokot/fix/following_icon_background
Fix slightly broken following icon background on UserPicture
2023-02-22 10:33:50 -05:00
Oleg Koretsky 520923e33a Fix slightly broken following icon background on UserPicture 2023-02-22 17:26:05 +02:00
Vitor Pamplona c5ceec8015 v0.17.7 2023-02-22 10:20:38 -05:00
Vitor Pamplona c0c2c668e3 Updating libraries 2023-02-22 10:19:30 -05:00
Vitor Pamplona 6d21653b36 init block is positionally aware.. in some phones... 2023-02-22 09:58:35 -05:00
Vitor Pamplona 39e8289375 Renders invalid tags as is. Should stop crashing in notifications. 2023-02-22 09:58:09 -05:00
Vitor Pamplona 87975f0ca2 Notifications from now on don't show the authors posts. Fixes: https://github.com/vitorpamplona/amethyst/issues/111 2023-02-21 21:52:16 -05:00
Vitor Pamplona fd70d05768 v0.17.6 2023-02-21 21:30:30 -05:00
Vitor Pamplona 85c7d0bb4a BugFix for alternating follow/unfollow when following multiple people in a row 2023-02-21 21:30:17 -05:00
Vitor Pamplona 63262a1e35 v0.17.5 2023-02-21 17:03:33 -05:00
Vitor Pamplona 987925d837 Reverting to a Class for the Notification View Model 2023-02-21 17:03:26 -05:00
Vitor Pamplona a2f14c3671 Speeding up userprofile, hidden user search and notification filter time 2023-02-21 16:40:27 -05:00
Vitor Pamplona 110c074993 Adds a space between user picture and name in quotes 2023-02-21 15:48:57 -05:00
Vitor Pamplona 4d56de3335 Observes user and note tags to help loading those from relays. 2023-02-21 15:48:40 -05:00
Vitor Pamplona 5ab3ce84d3 Saves Contact List locally to avoid losing follows. 2023-02-21 15:48:23 -05:00
Vitor Pamplona e8b09a9ba3 Refactors Notification Cache updates. 2023-02-21 11:33:28 -05:00
Vitor Pamplona 18f25aa4d8 Turns the Zap notification card in to ZapRequest/ZapEvent events. 2023-02-21 11:33:03 -05:00
Vitor Pamplona a34cf117a0 Makes each notification card trigger it's on Notification time update. 2023-02-21 11:32:10 -05:00
Vitor Pamplona 4b0d1d32a6 Adding an additional refresher of notification icons based on new notes in the database. 2023-02-21 11:31:35 -05:00
Vitor Pamplona ccf7d78cea v0.17.4 2023-02-21 09:44:17 -05:00
Vitor Pamplona bc2ef6500a Changes the language from Hide to Block to facilitate PlayStore approvals. 2023-02-21 09:43:34 -05:00
Vitor Pamplona 92b1744c35 Forces conversion of the ID to make sure it's a valid Hex ID and doesn't crash the app later. 2023-02-21 09:43:14 -05:00
Vitor Pamplona 2595d6fa2a Only adds user to Hidden Users after 5 duplicated messages. 2023-02-21 09:42:41 -05:00
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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 385a1c3849 Merge pull request #122 from veer66/main
Fix issue #121
2023-02-14 08:25:00 -05:00
Vee SatayamasandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 9faf6a2fbc Merge pull request #94 from Radiokot/fix/ui_issues
Fix some UI issues
2023-02-07 15:59:26 -05:00
Vitor PamplonaandGitHub f61b894ddf Merge branch 'main' into fix/ui_issues 2023-02-07 15:59:19 -05:00
Vitor PamplonaandGitHub 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 PamplonaandGitHub 2e2e54d642 Merge pull request #87 from greenart7c3/main
add debug configuration
2023-02-06 11:19:25 -05:00
Vitor PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub bb50099021 Merge pull request #75 from clackbib/habib/threading-perf-fixes
Scroll Perf Improvements.
2023-02-05 18:18:29 -05:00
Vitor PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 05267c598f Merge pull request #38 from KotlinGeekDev/rtl-fix
RTL Support
2023-01-25 17:25:27 -05:00
Vitor PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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 PamplonaandGitHub 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
290 changed files with 24380 additions and 9545 deletions
+32
View File
@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Video and Screenshots**
If applicable, add a video and/or screenshots to help explain your problem.
**Device (please complete the following information):**
- Phone Brand/Model [e.g. Pixel 7 Pro]:
- Android Version [e.g. 33]:
- App Version [e.g. v0.20.3]:
**Bounty (in Bitcoin sats) offered for a solution**
Incentivize developers to work on your issue. Describe clear milestones to claim payment.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Bounty (in Bitcoin sats) offered for the implementation**
Incentivize developers to work on your feature. Describe clear milestones to claim payment.
**Additional context**
Add any other context, video, or screenshots about the feature request here.
+3
View File
@@ -27,6 +27,9 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
- name: Linter (gradle)
run: ./gradlew ktlintCheck
- name: Test (gradle)
run: ./gradlew test --no-daemon
+102 -12
View File
@@ -28,10 +28,19 @@ jobs:
- name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
releaseDirectory: app/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -40,10 +49,19 @@ jobs:
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
- name: Sign APK
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
releaseDirectory: app/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -60,24 +78,96 @@ jobs:
draft: false
prerelease: false
- name: Upload APK Asset
id: upload-release-asset-apk
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref_name }}.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload AAB Asset
id: upload-release-asset-aab
- name: Upload Play APK x86 Asset
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/release/app-release.aab
asset_name: app-release-${{ github.ref_name }}.aab
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
+42 -19
View File
@@ -1,5 +1,3 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
@@ -11,9 +9,7 @@
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
# Built application files
@@ -36,6 +32,7 @@ out/
# release/
# Gradle files
.gradle
.gradle/
build/
@@ -48,25 +45,51 @@ proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
captures/
.navigation/
*.ipr
*~
*.swp
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
*.iws
/out/
deploymentTargetDropdown.xml
render.experimental.xml
# User-specific configurations
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
.idea/**/compiler.xml
.idea/**/copyright/profiles_settings.xml
.idea/**/encodings.xml
.idea/**/misc.xml
.idea/**/modules.xml
.idea/**/scopes/scope_settings.xml
.idea/**/dictionaries
.idea/**/vcs.xml
.idea/**/jsLibraryMappings.xml
.idea/**/datasources.xml
.idea/**/dataSources.ids
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/assetWizardSettings.xml
.idea/**/gradle.xml
.idea/**/jarRepositories.xml
.idea/**/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
Generated
+1
View File
@@ -0,0 +1 @@
Amethyst
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>
+3
View File
@@ -5,6 +5,9 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.10" />
</component>
</project>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+13
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
+63 -16
View File
@@ -6,27 +6,50 @@ Amethyst brings the best social network to your Android phone. Just insert your
# Current Features
- [x] Account Management
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image Preview (gifs, svgs)
- [x] Url Preview
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] User Profiles (edit/follow/unfollow - NIP-02)
- [x] Public Chats (NIP-28)
- [ ] Notification Bubbles
- [ ] Infinity Scroll
- [ ] Dropdown to Link Users/Posts when writting
- [ ] Identity Verification (NIP-05)
- [ ] Event Delegation (NIP-09)
- [ ] Filter messages from Unknown People
- [ ] Profile Edit
- [ ] Relay Edit
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback
- [x] Bech Encoding support (NIP-19)
- [x] Notification Dots
- [x] Reporting and Hide User capability (NIP-56)
- [x] Automatic Translations
- [x] Relay Sets (home, dms, public chats, global)
- [x] User/Note Tagging (NIP-08, NIP-10)
- [x] Lightning Tips
- [x] Zaps (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Identity Verification (NIP-05)
- [x] Long-form Content (NIP-23)
- [x] Parameterized Replaceable Events (NIP-33)
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Relay Authentication (NIP-42)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Message Sent feedback (NIP-20)
# Development Overview
@@ -75,6 +98,12 @@ Build the app:
./gradlew connectedAndroidTest
```
## Linting
```bash
./gradlew ktlintCheck
./gradlew ktlintFormat
```
## Installing on device
```bash
./gradlew installDebug
@@ -99,6 +128,24 @@ openssl base64 < <my-release-key.keystore> | tr -d '\n' | tee some_signing_key.j
7. Add your CHANGE LOG to the description of the new release
8. Download the `aab` file and upload it to the` PlayStore.
# Privacy on Relays & nostr
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of Private Zaps and Private DMs.
# DM Privacy #
While the content of direct messages (DMs) is only visible to you, and your DM nostr counterparty, everyone can see that and when you and your counterparty are DM-ing each other.
# Visibility & Permanence of Your Content on nostr
## Information Visibility ##
Content that you share can be shared to other relays.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to nostr users who do not share relays with you.
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
@@ -123,4 +170,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.
+127 -29
View File
@@ -1,6 +1,7 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
}
android {
@@ -11,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 13
versionName "0.9.2"
versionCode 99
versionName "0.27.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,23 +23,55 @@ 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"
}
}
flavorDimensions "channel"
productFlavors {
play {
dimension "channel"
}
fdroid {
dimension "channel"
}
}
splits {
abi {
enable true
reset()
include "x86", "x86_64"
universalApk true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.3.2'
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -48,29 +81,33 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
implementation 'androidx.compose.material:material:1.3.1'
// Needs this to open gallery / image upload
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha04'
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation "androidx.compose.material:material-icons-extended:1.3.1"
// Input
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
// Observe Live data as State
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
implementation "androidx.compose.material:material:$compose_ui_version"
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
// Lifecycle
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
// Biometrics
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// 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') {
@@ -79,39 +116,100 @@ dependencies {
exclude module: 'guava-testlib'
}
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:0.28.0"
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// upload pictures:
implementation "com.google.accompanist:accompanist-permissions:0.28.0"
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
implementation 'com.google.android.exoplayer:exoplayer:2.18.4'
// 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"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
testImplementation "io.mockk:mockk:1.13.4"
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
}
}
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}
+17 -1
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 com.vitorpamplona.amethyst.service.model.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.lnurl.** { *; }
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Client
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSigCheck {
val payload1 = "[\"EVENT\",\"40b9\",{\"id\":\"48a72b485d38338627ec9d427583551f9af4f016c739b8ec0d6313540a8b12cf\",\"kind\":1,\"pubkey\":\"3d842afecd5e293f28b6627933704a3fb8ce153aa91d790ab11f6a752d44a42d\",\"created_at\":1677940007,\"content\":\"I got asked about follower count again today. Why does my follower count go down when I delete public relays (in our list) and replace them with filter.nostr.wine? \\n\\nIll give you one final explanation to rule them all. First, lets go over how clients calculate your follower count.\\n\\n1. Your client sends a request to all your connected relays asking for accounts who follow you\\n2. Relays answer back with the events requested\\n3. The client aggregates the event total and displays it\\n\\nEach relay has a set limit on how many stored events it will return per request. For some relays its 500, others 1000, some as high as 5000. Lets say for simplicity that all your public relays use 500 as their limit. If you ask 10 relays for your followers the max possible answer you can get is 5000. That wont change if you have 20,000 followers or 100,000. You may get back a “different” 5000 each time, but youll still cap out at 5000 because that is the most events your client will receive.\u2028\u2028Our limit on filter.nostr.wine is 2000 events. If you replace 10 public relays with only filter.nostr.wine, the MOST followers you will ever get back from our filter relay is 2000. That doesnt mean you only have 2000 followers or that your reach is reduced in any way.\\n\\nAs long as you are writing to and reading from the same public relays, neither your reach nor any content was lost. That concludes my TED talk. I hope you all have a fantastic day and weekend.\",\"tags\":[],\"sig\":\"dcaf8ab98bb9179017b35bd814092850d1062b26c263dff89fb1ae8c019a324139d1729012d9d05ff0a517f76b1117d869b2cc7d36bea8aa5f4b94c5e2548aa8\"}]"
@Test
fun testUnicode2028and2029ShouldNotBeEscaped() {
val msg = Event.gson.fromJson(payload1, JsonElement::class.java).asJsonArray
val event = Event.fromJson(msg[2], Client.lenient)
// Should pass
event.checkSignature()
}
}
@@ -5,9 +5,11 @@ 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.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -17,80 +19,81 @@ import org.junit.runner.RunWith
* 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)
}
class UrlUserTagTransformationTest {
@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"))
user.info.displayName = "Vitor Pamplona"
@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
)
val transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
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(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(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(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))
}
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"
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
user.info.displayName = "Vitor Pamplona"
val transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
Color.Red
)
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
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(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(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 @
}
}
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 @
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
</resources>
@@ -0,0 +1,80 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.gms.tasks.Tasks
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translatePT(text: String, translateTo: String): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result
}
fun assertTranslate(expected: String, input: String, translateTo: String) {
assertEquals(null, expected, translatePT(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
val translated = translatePT(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@Test
fun testTranslation() {
assertTranslate("Olá mundo", "Hello World", "pt")
}
@Test
fun testTranslationName() {
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
}
@Test
fun testTranslationTag() {
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
}
@Test
fun testTranslationUrl() {
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
}
@Test
fun testChineseWithUrlDetector() {
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿,也许是你的关注人数或者其他数据太多了,其他人主页没有这么卡顿。来自amethyst客户端", "en")
}
@Test
fun testTranslationEmail() {
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
}
@Test
fun testTranslationLnInvoice() {
assertTranslateContains(
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay",
"pt"
)
assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"pt"
)
}
}
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
+26 -4
View File
@@ -4,9 +4,16 @@
<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:name=".Amethyst"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
@@ -15,21 +22,36 @@
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
android:exported="true"
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>
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
</application>
</manifest>
@@ -0,0 +1,15 @@
package com.vitorpamplona.amethyst
import android.app.Application
class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
instance = this
}
companion object {
lateinit var instance: Amethyst
private set
}
}
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
fun prefsFileName(npub: String? = null): String {
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
}
fun preferences(npub: String? = null): EncryptedSharedPreferences {
val context = Amethyst.instance
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val preferencesName = prefsFileName(npub)
return EncryptedSharedPreferences.create(
context,
preferencesName,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) as EncryptedSharedPreferences
}
}
@@ -1,22 +0,0 @@
package com.vitorpamplona.amethyst
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
class KeyStorage {
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
val preferencesName = "secret_keeper"
return EncryptedSharedPreferences.create(
preferencesName,
secretKey,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) as EncryptedSharedPreferences
}
}
@@ -0,0 +1,306 @@
package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.toHex
import nostr.postr.toNpub
import java.io.File
import java.util.Locale
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
// To use plaintext SharedPreferences for debugging, set this to true
// It will only apply in Debug builds
private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val current: Boolean,
val displayName: String?,
val profilePicture: String?
)
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val DISPLAY_NAME = "display_name"
const val PROFILE_PICTURE_URL = "profile_picture"
const val FOLLOWING_CHANNELS = "following_channels"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
const val LANGUAGE_PREFS = "languagePreferences"
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
private val gson = GsonBuilder().create()
object LocalPreferences {
private const val comma = ","
private var currentAccount: String?
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
set(npub) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.CURRENT_ACCOUNT, npub)
}.apply()
}
private val savedAccounts: List<String>
get() = encryptedPreferences()
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
private fun addAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
if (npub !in accounts) {
accounts.add(npub)
}
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
private fun setCurrentAccount(account: Account) {
val npub = account.userProfile().pubkeyNpub()
currentAccount = npub
addAccount(npub)
}
fun switchToAccount(npub: String) {
currentAccount = npub
}
/**
* Removes the account from the app level shared preferences
*/
private fun removeAccount(npub: String) {
val accounts = savedAccounts.toMutableList()
if (accounts.remove(npub)) {
val prefs = encryptedPreferences()
prefs.edit().apply {
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
}.apply()
}
}
/**
* Deletes the npub-specific shared preference file
*/
private fun deleteUserPreferenceFile(npub: String) {
val prefsDir = File(prefsDirPath)
prefsDir.list()?.forEach {
if (it.contains(npub)) {
File(prefsDir, it).delete()
}
}
}
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
return EncryptedStorage.preferences(npub)
}
}
/**
* Clears the preferences for a given npub, deletes the preferences xml file,
* and switches the user to the first account in the list if it exists
*
* We need to use `commit()` to write changes to disk and release the file
* lock so that it can be deleted. If we use `apply()` there is a race
* condition and the file will probably not be deleted
*/
@SuppressLint("ApplySharedPref")
fun updatePrefsForLogout(npub: String) {
val userPrefs = encryptedPreferences(npub)
userPrefs.edit().clear().commit()
removeAccount(npub)
deleteUserPreferenceFile(npub)
if (savedAccounts.isEmpty()) {
val appPrefs = encryptedPreferences()
appPrefs.edit().clear().apply()
} else if (currentAccount == npub) {
currentAccount = savedAccounts.elementAt(0)
}
}
fun updatePrefsForLogin(account: Account) {
setCurrentAccount(account)
saveToEncryptedStorage(account)
}
fun allSavedAccounts(): List<AccountInfo> {
return savedAccounts.map { npub ->
val prefs = encryptedPreferences(npub)
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
AccountInfo(
npub = npub,
hasPrivKey = hasPrivKey,
current = npub == currentAccount,
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
)
}
}
fun saveToEncryptedStorage(account: Account) {
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
prefs.edit().apply {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.gson.fromJson(it, Event::class.java)
.getRefinedEvent(true) as ContactListEvent
}
} catch (e: Throwable) {
e.printStackTrace()
null
}
val languagePreferences = try {
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, String>>() {}.type
) as Map<String, String>
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
mapOf()
}
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
)
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
val stringPrefs = listOf(
PrefKeys.NOSTR_PRIVKEY,
PrefKeys.NOSTR_PUBKEY,
PrefKeys.RELAYS,
PrefKeys.LANGUAGE_PREFS,
PrefKeys.TRANSLATE_TO,
PrefKeys.ZAP_AMOUNTS,
PrefKeys.LATEST_CONTACT_LIST
)
val stringSetPrefs = listOf(
PrefKeys.FOLLOWING_CHANNELS,
PrefKeys.HIDDEN_USERS,
PrefKeys.DONT_TRANSLATE_FROM
)
encryptedPreferences().apply {
val appPrefs = this
encryptedPreferences(npub).edit().apply {
val userPrefs = this
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
encryptedPreferences().edit().clear().apply()
addAccount(npub)
currentAccount = npub
}
}
@@ -0,0 +1,68 @@
package com.vitorpamplona.amethyst
import androidx.lifecycle.LiveData
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 java.util.concurrent.atomic.AtomicBoolean
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData()
}
}
}
fun load(route: String): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
}
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)
@@ -0,0 +1,78 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
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
import com.vitorpamplona.amethyst.service.relays.Constants
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
// 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()
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.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
}
}
}
@@ -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
}
}
@@ -1,156 +0,0 @@
package com.vitorpamplona.amethyst.lnurl
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) {
"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
}
}
@@ -1,240 +1,627 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.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.Contact
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.Contact
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
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.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
var seeReplies: Boolean = true
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
}
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
}
@OptIn(DelicateCoroutinesApi::class)
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 languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() } != null) {
// has already liked this note
return
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
note.event?.let {
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun boost(note: Note) {
if (!isWriteable()) return
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
}
}
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!)
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
Client.send(event)
LocalCache.consume(event)
}
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().lastestContactList
if (lastestContactList != null) {
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
if (!isWriteable()) return
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
if (!isWriteable()) return
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
relays,
loggedIn.privKey!!
)
val signedEvent = ChannelMessageEvent.create(
message = message,
channel = toChannel,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
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)
}
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) {
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(), accountStateViewModel)
}
fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
followingChannels.add(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
followingChannels.remove(idHex)
accountStateViewModel.saveToEncryptedStorage(this)
}
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)
followingChannels.add(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)
Client.send(event)
LocalCache.consume(event)
} else {
retVal
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
LocalCache.consume(event)
}
}
fun sendNewUserMetadata(toString: String, identities: List<IdentityClaim>) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val event = MetadataEvent.create(toString, identities, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun reactionTo(note: Note): List<Note> {
return note.reactedBy(userProfile(), "+")
}
fun hasBoosted(note: Note): Boolean {
return boostsTo(note).isNotEmpty()
}
fun boostsTo(note: Note): List<Note> {
return note.boostedBy(userProfile())
}
fun hasReacted(note: Note): Boolean {
return note.hasReacted(userProfile(), "+")
}
fun reactTo(note: Note) {
if (!isWriteable()) return
if (hasReacted(note)) {
// has already liked this note
return
}
} catch (e: Exception) {
e.printStackTrace()
null
}
} else {
event?.content
note.event?.let {
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
if (!isWriteable()) return null
private fun refreshObservers() {
live.refresh()
}
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: 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().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
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!!, content = content)
Client.send(event)
LocalCache.consume(event, null)
}
}
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, null)
}
fun delete(note: Note) {
delete(listOf(note))
}
fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
if (myNotes.isNotEmpty()) {
val event = DeletionEvent.create(myNotes, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun boost(note: Note) {
if (!isWriteable()) return
if (note.hasBoostedInTheLast5Minutes(userProfile())) {
// has already bosted in the past 5mins
return
}
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun broadcast(note: Note) {
note.event?.let {
Client.send(it)
}
}
fun follow(user: User) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
contactList.relays(),
loggedIn.privKey!!
)
} else {
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(event)
LocalCache.consume(event)
}
fun unfollow(user: User) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
contactList.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 addresses = replyTo?.mapNotNull { it.address() }
val signedEvent = TextNoteEvent.create(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
addresses = addresses,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(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 sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = emptyList<String>()
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
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)
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
live.invalidateData()
saveable.invalidateData()
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
live.invalidateData()
saveable.invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
saveable.invalidateData()
}
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)
}
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
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else {
event?.content()
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
liveLanguages.invalidateData()
saveable.invalidateData()
}
fun updateTranslateTo(languageCode: String) {
translateTo = languageCode
liveLanguages.invalidateData()
saveable.invalidateData()
}
fun prefer(source: String, target: String, preference: String) {
languagePreferences = languagePreferences + Pair("$source,$target", preference)
saveable.invalidateData()
}
fun preferenceBetween(source: String, target: String): String? {
return languagePreferences.get("$source,$target")
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
backupContactList = newContactList
saveable.invalidateData()
}
}
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
} ?: return null
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (usersRelayList.none { it.activeTypes.contains(FeedType.SEARCH) }) {
usersRelayList = usersRelayList + Relay(
Constants.forcedRelayForSearch.url,
Constants.forcedRelayForSearch.read,
Constants.forcedRelayForSearch.write,
Constants.forcedRelayForSearch.feedTypes
)
}
return usersRelayList.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()
}
}
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
fun isFollowing(user: User): Boolean {
return user.pubkeyHex in followingKeySet()
}
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().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
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) })
saveable.invalidateData()
}
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
}
init {
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
// Observes relays to restart connections
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged()
}
}
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
}
// imports transient blocks due to spam.
LocalCache.antiSpam.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
}
}
}
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
fun refresh() {
postValue(AccountState(account))
}
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
override fun onActive() {
super.onActive()
}
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
override fun onInactive() {
super.onInactive()
}
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() {
postValue(AccountState(account))
}
}
class AccountState(val account: Account)
class AccountState(val account: Account)
@@ -0,0 +1,87 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.model.Event
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 java.util.concurrent.atomic.AtomicBoolean
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event): Boolean {
val idHex = event.id
// if short message, ok
if (event.content.length < 50) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
// Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, Spammer(event.pubKey, setOf(recentMessages[hash], event.id)))
liveSpam.invalidateData()
} else {
val spammer = spamMessages.get(hash)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
liveSpam.invalidateData()
}
return true
}
recentMessages.put(hash, idHex)
return false
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(AntiSpamState(cache))
}
}
class AntiSpamState(val cache: AntiSpamFilter)
@@ -1,32 +1,34 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import fr.acinq.secp256k1.Hex
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.events.ContactListEvent
class Channel(val id: ByteArray) {
val idHex = id.toHexKey()
val idDisplayHex = id.toShortenHex()
class Channel(val idHex: String) {
var creator: User? = null
var info = ChannelCreateEvent.ChannelData(null, null, null)
var updatedMetadataAt: Long = 0;
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
}
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
}
fun addNote(note: Note) {
notes[note.idHex] = note
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
@@ -37,9 +39,9 @@ class Channel(val id: ByteArray) {
live.refresh()
}
fun profilePicture(): String {
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${idHex}.png"
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
@@ -50,16 +52,38 @@ class Channel(val id: ByteArray) {
// 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.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)) {
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)
@@ -1,39 +1,102 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import nostr.postr.Bech32
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
import nostr.postr.toNpub
/** Makes the distinction between String and Hex **/
typealias HexKey = String
typealias NPubKey = String
typealias NoteId = String
fun NPubKey.toDisplayKey(): String {
return this.toShortenHex()
}
fun NoteId.toDisplayId(): String {
return this.toShortenHex()
}
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toHexKey(): HexKey {
return toHex()
return toHex()
}
fun HexKey.toByteArray(): ByteArray {
return Hex.decode(this)
return Hex.decode(this)
}
fun HexKey.toDisplayHexKey(): String {
return this.toShortenHex()
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)
}
}
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else { //if (pattern.matcher(key).matches()) {
//} else {
Hex.decode(key)
}
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
if (key.startsWith("nostr:", true)) {
key = key.substring("nostr:".length)
}
key = key.removePrefix("@")
if (key.length < 63) {
return null
}
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
// Converts to npub
val pubkey = Nip19.uriToRoute(Persona(privKey = keyB32.bechToBytes()).pubKey.toNpub()) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("npub1", true)) {
val pubkey = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(pubkey, restOfWord)
} else if (key.startsWith("note1", true)) {
val noteId = Nip19.uriToRoute(keyB32) ?: return null
return DirtyKeyInfo(noteId, restOfWord)
} else if (key.startsWith("nprofile", true)) {
val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(pubkeyRelay, restOfWord)
} else if (key.startsWith("nevent", true)) {
val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(noteRelayId, restOfWord)
} else if (key.startsWith("naddr1", true)) {
val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null
return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
File diff suppressed because it is too large Load Diff
@@ -1,49 +1,82 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
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.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.Event
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayHex = id.toShortenHex()
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
}
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: Event? = null
var event: EventInterface? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: MutableList<Note>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
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
var channel: Channel? = null
var relays = setOf<String>()
private set
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
var lastReactionsDownloadTime: Long? = null
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun channel(): Channel? {
val channelHex =
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.let { it.id }
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
}
open fun address() = (event as? LongTextNoteEvent)?.address()
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
invalidateData()
liveSet?.metadata?.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -51,41 +84,121 @@ 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 "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
return replyTo
.map { it.replyLevelSignature() }
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(event?.createdAt ?: 0) + ";"
.map {
cachedSignatures[it] ?: it.replyLevelSignature(cachedSignatures).apply { cachedSignatures.put(it, this) }
}
.maxBy { it.length }.removeSuffix(";") + "/" + formattedDateTime(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))
invalidateData()
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
}
}
fun removeReply(note: Note) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
fun removeBoost(note: Note) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
}
fun removeReaction(note: Note) {
reactions = reactions - note
liveSet?.reactions?.invalidateData()
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
}
}
}
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (boosts.add(note))
invalidateData()
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))
invalidateData()
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 {
@@ -96,38 +209,185 @@ 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()
}
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): 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?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
} ?: false
)
}
fun directlyCiteUsersHex(): Set<HexKey> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content() ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags()?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile ||
(userProfile in directlyCiteUsers()) ||
(event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true) ||
(event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactedBy(loggedIn, content).isNotEmpty()
}
fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions.filter { it.author == loggedIn && it.event?.content() == content }
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet = null
}
}
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
fun refresh() {
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 metadata.hasObservers() ||
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()
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(NoteState(note))
}
override fun onActive() {
super.onActive()
NostrSingleEventDataSource.add(note.idHex)
if (note is AddressableNote) {
NostrSingleEventDataSource.addAddress(note)
} else {
NostrSingleEventDataSource.add(note)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleEventDataSource.remove(note.idHex)
if (note is AddressableNote) {
NostrSingleEventDataSource.removeAddress(note)
} else {
NostrSingleEventDataSource.remove(note)
}
}
}
@@ -0,0 +1,14 @@
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 spamCount: Int = 0,
val feedTypes: Set<FeedType>
)
@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.model.ATag
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
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
// 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 = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
if (aTag != null) {
LocalCache.getOrCreateAddressableNote(aTag)
} else {
return emptySet()
}
} else {
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)
}
}
}
}
@@ -7,56 +7,69 @@ 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 {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
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
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
callback?.onComplete(urlInfo)
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
callback?.onComplete(it)
return
}
override fun onFailed(throwable: Throwable) {
callback?.onFailed(throwable)
failures[url]?.let {
callback?.onFailed(it)
return
}
}).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()
}
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)
}
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 {
previewInfo(it)
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].lowercase()
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")
}
}
}
}
}
@@ -1,133 +1,343 @@
package com.vitorpamplona.amethyst.model
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import fr.acinq.secp256k1.Hex
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 nostr.postr.events.ContactListEvent
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import nostr.postr.toNpub
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
val pubkeyDisplayHex = pubkey.toShortenHex()
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
var lastestContactList: ContactListEvent? = null
var notes = setOf<Note>()
private set
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var reports = mapOf<User, Set<Note>>()
private set
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
var latestReportTime: Long = 0
var zaps = mapOf<Note, Note?>()
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var privateChatrooms = mapOf<User, Chatroom>()
private set
var acceptedBadges: AddressableNote? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
override fun toString(): String = pubkeyHex
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 nip05(): String? {
return info?.nip05?.ifBlank { null }
}
fun follow(user: User) {
follows.add(user)
user.followers.add(this)
invalidateData()
user.invalidateData()
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
invalidateData()
user.invalidateData()
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
liveSet?.relays?.invalidateData()
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
// No need for Listener yet
}
}
fun removeNote(note: Note) {
notes = notes - note
}
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.createdAt() ?: 0
if (reportTime > latestReportTime) {
latestReportTime = reportTime
}
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
}
}
}
fun updateAcceptedBadges(note: AddressableNote) {
acceptedBadges = note
liveSet?.badges?.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 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<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
return messages[user] ?: run {
val channel = mutableSetOf<Note>()
messages[user] = channel
channel
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) {
getOrCreateChannel(user).add(msg)
live.refresh()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
liveSet?.messages?.invalidateData()
}
}
fun updateFollows(newFollows: List<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
fun removeMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
liveSet?.messages?.invalidateData()
}
val toBeRemoved = synchronized(follows) {
follows - newFollows
}
toBeAdded.forEach {
follow(it)
}
toBeRemoved.forEach {
unfollow(it)
}
updatedFollowsAt = updateAt
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
fun addRelayBeingUsed(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 updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
updatedMetadataAt = updateAt
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
invalidateData()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.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()
}
fun isFollowing(user: User): Boolean {
return synchronized(follows) {
follows.contains(user)
}
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
// Observers line up here.
val live: UserLiveData = UserLiveData(this)
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
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).reportedAuthor().any { it.reportType == 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() == false) {
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)
val badges: UserLiveData = UserLiveData(u)
fun isInUse(): Boolean {
return follows.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() ||
badges.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
@@ -137,7 +347,11 @@ class UserMetadata {
var banner: String? = null
var website: String? = null
var about: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
@@ -147,25 +361,48 @@ class UserMetadata {
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()
.any { it.startsWith(prefix, true) }
}
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
fun refresh() {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
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)
}
}
@@ -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: List<JsonFilter>? = null // Inactive when null
}
@@ -1,25 +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),
Relay("wss://relay.nostr.ch", read = true, write = true)
)
}
@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
return null
}
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
}
withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $nip05. Error: ${it.code}. Check if the server up and if the address $nip05 is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the address $nip05 is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '$url': ${e.message}")
}
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
val nip05url = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError
)
}
}
@@ -1,74 +1,93 @@
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.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
lateinit var account: Account
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 createAccountContactListFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
}
fun createAccountMetadataFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 3
)
}
val accountMetadataChannel = requestNewChannel()
val accountContactListChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event?.createdAt }
.reversed()
}
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
val newAccountMetadataFilter = createAccountMetadataFilter()
accountMetadataChannel.filter = listOf(newAccountMetadataFilter).ifEmpty { null }
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
val newAccountContactListEvent = createAccountContactListFilter()
accountContactListChannel.filter = listOf(newAccountContactListEvent).ifEmpty { null }
}
}
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,
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)
)
val accountChannel = requestNewChannel()
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter()
).ifEmpty { null }
}
}
@@ -1,37 +1,37 @@
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 nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
var channel: com.vitorpamplona.amethyst.model.Channel? = null
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.channels[channelId]
resetFilters()
}
fun createMessagesToChannelFilter(): JsonFilter? {
if (channel != null) {
return JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
)
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
return null
}
val messagesChannel = requestNewChannel()
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
}
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList()
}
val messagesChannel = requestNewChannel()
override fun updateChannelFilters() {
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}
@@ -1,45 +0,0 @@
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 nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
account = accountIn
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = withUser?.let { listOf(it.pubkeyHex) },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = withUser?.let { mapOf("p" to listOf(it.pubkeyHex)) }
)
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser]
return messages?.sortedBy { it.event?.createdAt } ?: emptyList()
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
}
}
@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
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 }
}
}
@@ -1,95 +1,88 @@
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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
lateinit var account: Account
object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = 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() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.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() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
)
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<JsonFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
}
}
fun createLastMessageOfEachChannelFilter(): List<JsonFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val myChannelsChannel = requestNewChannel()
val myChannelsInfoChannel = requestNewChannel()
val myChannelsMessagesChannel = requestNewChannel()
val myChannelsCreatedbyMeChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList()
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
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
)
)
}
}
val publicChannels = account.followingChannels().map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
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 = 100 // Remember to consider spam that is being removed from the UI
)
)
}
}
val channelsCreatedByMe = LocalCache.channels.values.filter {
it.creator == account.userProfile()
}.map {
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
val chatroomListChannel = requestNewChannel()
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
)
chatroomListChannel.typedFilters = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()
).flatten().ifEmpty { null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
myChannelsChannel.filter = listOf(createMyChannelsFilter()).ifEmpty { null }
myChannelsInfoChannel.filter = createLastChannelInfoFilter().ifEmpty { null }
myChannelsMessagesChannel.filter = createLastMessageOfEachChannelFilter().ifEmpty { null }
//myChannelsCreatedbyMeChannel.filter = listOf(createChannelsCreatedbyMeFilter()).ifEmpty { null }
}
}
}
@@ -1,213 +1,222 @@
package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Collections
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import com.vitorpamplona.amethyst.service.relays.Subscription
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 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
import nostr.postr.events.TextNoteEvent
import kotlinx.coroutines.withContext
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
abstract class NostrDataSource<T>(val debugName: String) {
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
data class Counter(var counter: Int)
private val eventCounter = mutableMapOf<String, Int>()
private var eventCounter = mapOf<String, Counter>()
fun printCounter() {
eventCounter.forEach {
println("AAA Count ${it.key}: ${it.value}")
fun printCounter() {
eventCounter.forEach {
Log.d("STATE DUMP", "Received Events ${it.key}: ${it.value.counter}")
}
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channelIds) {
val key = "${debugName} ${subscriptionId} ${event.kind}"
if (eventCounter.contains(key)) {
eventCounter.put(key, eventCounter.get(key)!! + 1)
} else {
eventCounter.put(key, 1)
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
is ChannelMetadataEvent -> LocalCache.consume(event)
is ChannelMuteUserEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is LnZapEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
//println("AAA ${debugName} ${subscriptionId} ${event.kind}")
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))
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))
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))
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
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."
// Relay.Type.DISCONNECTING -> "disconnecting."
// Relay.Type.EOSE -> "sent all events it had stored."
// }}")
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) {
}
}
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
init {
Client.subscribe(clientListener)
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
// Relay.Type.DISCONNECTING -> "disconnecting."
// 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
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
}*/
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
}
}
init {
Client.subscribe(clientListener)
}
open fun start() {
resetFilters()
}
open fun stop() {
channels.forEach { channel ->
if (channel.filter != null) // if it is active, close
Client.close(channel.id)
}
}
fun loadTop(): List<T> {
val returningList = feed().take(100)
// prepare previews
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
loadPreviews(returningList)
}
return returningList
}
fun loadPreviews(list: List<T>) {
list.forEach {
if (it is Note) {
UrlCachedPreviewer.preloadPreviewsFor(it)
}
}
}
fun requestNewChannel(): Channel {
val newChannel = Channel()
channels.add(newChannel)
channelIds.add(newChannel.id)
return newChannel
}
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 }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
channels.forEach { channel ->
val channelsNewFilter = channel.filter
if (channel in activeChannels) {
if (channelsNewFilter == null) {
// was active and is not active anymore, just close.
Client.close(channel.id)
} else {
// was active and is still active, check if it has changed.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
open fun stop() {
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
Client.close(channel.id)
Client.sendFilter(channel.id, channelsNewFilter)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(channel.id, channelsNewFilter)
}
channel.typedFilters = null
}
} else {
if (channelsNewFilter == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.sendFilter(channel.id, channelsNewFilter)
}
}
}
}
}
abstract fun updateChannelFilters()
abstract fun feed(): List<T>
}
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 dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateFilters() {
if (handlerWaiting.getAndSet(true)) return
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
try {
delay(200)
resetFiltersSuspend()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun resetFilters() {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
resetFiltersSuspend()
}
}
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
} else {
if (updatedSubscriotionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
}
}
}
abstract fun updateChannelFilters()
}
@@ -1,26 +1,24 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
limit = 50
)
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values
.filter {
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
override fun updateChannelFilters() {
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {
globalFeedChannel.filter = listOf(createGlobalFilter()).ifEmpty { null }
}
}
}
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
hashtagToWatch = tag
invalidateFilters()
}
}
@@ -1,90 +1,66 @@
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.RepostEvent
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
lateinit var account: Account
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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 createFollowAccountsFilter(): JsonFilter {
val follows = account.userProfile().follows ?: emptySet()
val followKeys = synchronized(follows) {
follows.map {
it.pubkey.toHex().substring(0, 6)
}
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 200
)
}
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
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
@OptIn(DelicateCoroutinesApi::class)
override fun start() {
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.observeForever(cacheListener)
}
}
super.start()
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
val newFollowAccountsFilter = createFollowAccountsFilter()
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter?.firstOrNull())) {
followAccountChannel.filter = listOf(newFollowAccountsFilter).ifEmpty { null }
@OptIn(DelicateCoroutinesApi::class)
override fun stop() {
super.stop()
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
}
}
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.followingKeySet()
val followKeys = follows.map {
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
authors = followSet,
limit = 400
)
)
}
val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
}
@@ -1,29 +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<Note>("NotificationFeed") {
lateinit var account: Account
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 100
)
val notificationChannel = requestNewChannel()
override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }
}
return filtered.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
notificationChannel.filter = listOf(createNotificationFilter()).ifEmpty { null }
}
}
@@ -0,0 +1,90 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private var searchString: String? = null
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString == null) {
return null
}
val hexToWatch = try {
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
decodePublicKey(mySearchString).toHex()
} else if (mySearchString.startsWith("note")) {
mySearchString.bechToBytes().toHex()
} else {
mySearchString
}
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
null
}
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)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 20
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)
)
)
}
val searchChannel = requestNewChannel()
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(searchString: String) {
this.searchString = searchString
invalidateFilters()
}
fun clear() {
searchString = null
}
}
@@ -0,0 +1,69 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
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()
}
}
@@ -1,80 +1,185 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.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.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf<Note>()
private var addressesToWatch = setOf<Note>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
private fun createTagToAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
if (reactionsToWatch.isEmpty()) {
return null
if (addressesToWatch.isEmpty()) {
return null
}
val now = Date().time / 1000
return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
)
)
}
}
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
}
private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = addressesToWatch.filter { it.event == null }
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val directEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.filter { it.event == null }
if (addressesToWatch.isEmpty()) {
return null
}
val threadingEventsToLoad = eventsToWatch
.mapNotNull { LocalCache.notes[it] }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
val now = Date().time / 1000
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex }
if (interestedEvents.isEmpty()) {
return null
return addressesToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
)
)
}
}
}
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
ids = interestedEvents
)
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
if (reactionsToWatch.isEmpty()) {
return null
}
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
val now = Date().time / 1000
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind,
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
)
)
}
}
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
}
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.filter { it.event == null }
fun add(eventId: String) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
val threadingEventsToLoad = eventsToWatch
.mapNotNull { it.replyTo }
.flatten()
.filter { it !is AddressableNote && it.event == null }
fun remove(eventId: String) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
.map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind
),
ids = interestedEvents.toList()
)
)
)
}
val singleEventChannel = requestNewChannel { time ->
eventsToWatch.forEach {
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() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createTagToAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
}
fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: Note) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
fun addAddress(aTag: Note) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
}
fun removeAddress(aTag: Note) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
}
}
@@ -1,45 +1,65 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
var usersToWatch = setOf<String>()
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<JsonFilter>? {
if (usersToWatch.isEmpty()) return null
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it.substring(0, 8)),
limit = 1
)
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()
}
return usersToWatch.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestReportTime
)
)
}
}
override fun updateChannelFilters() {
userChannel.filter = createUserFilter()
}
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()
}
fun add(userId: String) {
usersToWatch = usersToWatch.plus(userId)
invalidateFilters()
}
val userChannelOnce = requestNewChannel()
fun remove(userId: String) {
usersToWatch = usersToWatch.minus(userId)
invalidateFilters()
}
}
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
}
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
@@ -1,86 +1,43 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val threadToLoad = eventToWatch ?: return null
if (reactionsToWatch.isEmpty()) {
return null
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex }
.toSet()
.ifEmpty { null } ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val eventsToLoad = eventsToWatch
.map { LocalCache.notes[it] }
.filterNotNull()
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
if (eventsToLoad.isEmpty()) {
return null
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()
}
return JsonFilter(
ids = eventsToLoad
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId] ?: return
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)
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
}
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
invalidateFilters()
}
}
@@ -1,49 +1,117 @@
package com.vitorpamplona.amethyst.service
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.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
var user: User? = null
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
fun createUserInfoFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
}
fun createUserPostsFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
)
}
val userInfoChannel = requestNewChannel()
val notesChannel = requestNewChannel()
override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.sortedBy { it.event?.createdAt }
resetFilters()
}
return sortedNotes.reversed()
}
override fun updateChannelFilters() {
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
}
}
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, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
)
}
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))
)
)
}
fun createAcceptedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createReceivedAwardsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BadgeAwardEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)),
limit = 20
)
)
}
val userInfoChannel = requestNewChannel()
override fun updateChannelFilters() {
userInfoChannel.typedFilters = listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter()
).ifEmpty { null }
}
}
@@ -1,34 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val followerChannel = requestNewChannel()
override fun feed(): List<User> {
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.toList()
}
}
override fun updateChannelFilters() {
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
}
}
@@ -1,33 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
fun createFollowFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
}
val followChannel = requestNewChannel()
override fun feed(): List<User> {
return user?.follows?.toList() ?: emptyList()
}
override fun updateChannelFilters() {
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
}
}
@@ -0,0 +1,177 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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
import java.net.URLEncoder
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.lowercase().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()
.header("User-Agent", "Amethyst")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, nostrRequest, onSuccess, onError)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
withContext(Dispatchers.IO) {
val encodedMessage = URLEncoder.encode(message, "utf-8")
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
var url = "$lnCallback${urlBinder}amount=$milliSats&comment=$encodedMessage"
if (nostrRequest != null) {
val encodedNostrRequest = URLEncoder.encode(nostrRequest, "utf-8")
url += "&nostr=$encodedNostrRequest"
}
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(response.body.string())
} else {
onError("Could not fetch invoice from $lnCallback")
}
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
}
}
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 = { lnAddressJson ->
val lnurlp = try {
mapper.readTree(lnAddressJson)
} 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 { cb ->
fetchLightningInvoice(
cb,
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("Invoice Not Created (element pr not found in the resulting JSON)")
},
onError = onError
)
}
},
onError = onError
)
}
}
@@ -0,0 +1,173 @@
package com.vitorpamplona.amethyst.service.lnurl
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.lowercase()) {
"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
}
}
/**
* If the string contains an LN invoice, returns a Pair of the start and end
* positions of the invoice in the string. Otherwise, returns (0, 0). This is
* used to ensure we don't accidentally cut an invoice in the middle when taking
* only a portion of the available text.
*/
fun locateInvoice(input: String?): Pair<Int, Int> {
if (input == null) {
return Pair(0, 0)
}
val matcher = invoicePattern.matcher(input)
return if (matcher.find()) {
Pair(matcher.start(), matcher.end())
} else {
Pair(0, 0)
}
}
}
@@ -0,0 +1,81 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
import fr.acinq.secp256k1.Hex
import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
fun toNAddr(): String {
val kind = kind.toByteArray()
val author = pubKeyHex.toByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, dTag.size.toByte()) + dTag
if (relay != null) {
fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay
}
fullArray = fullArray +
byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author +
byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
}
companion object {
fun isATag(key: String): Boolean {
return key.startsWith("naddr1") || key.contains(":")
}
fun parse(address: String, relay: String?): ATag? {
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr")) {
parseNAddr(address)
} else {
parseAtag(address, relay)
}
}
fun parseAtag(atag: String, relay: String?): ATag? {
return try {
val parts = atag.split(":")
Hex.decode(parts[1])
ATag(parts[0].toInt(), parts[1], parts[2], relay)
} catch (t: Throwable) {
Log.w("ATag", "Error parsing A Tag: $atag: ${t.message}")
null
}
}
fun parseNAddr(naddr: String): ATag? {
try {
val key = naddr.removePrefix("nostr:")
if (key.startsWith("naddr")) {
val tlv = Tlv.parse(key.bechToBytes())
val d = tlv.get(Tlv.Type.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
val relay = tlv.get(Tlv.Type.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)?.get(0)?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)?.get(0)?.let { Tlv.toInt32(it) }
if (kind != null && author != null) {
return ATag(kind, author, d, relay)
}
}
} catch (e: Throwable) {
Log.w("ATag", "Issue trying to Decode NIP19 $this: ${e.message}")
// e.printStackTrace()
}
return null
}
}
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
class BadgeAwardEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 8
}
}
@@ -0,0 +1,24 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
class BadgeDefinitionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
companion object {
const val kind = 30009
}
}
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
class BadgeProfilesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 30008
}
}
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
}
@@ -1,44 +1,48 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import java.util.Date
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 {
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
class ChannelCreateEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channelInfo(): ChannelData = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null)
}
}
companion object {
const val kind = 40
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = if (channelInfo != null)
gson.toJson(channelInfo)
else
""
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)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = emptyList<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
}
@@ -1,38 +1,33 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
import java.util.Date
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>
class ChannelHideMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
init {
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 43
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {
listOf("e", it)
} ?: emptyList()
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)
val id = generateId(pubKey, createdAt, kind, tags, reason)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, reason, sig.toHexKey())
}
}
}
}
}
@@ -1,47 +1,42 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
import java.util.Date
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>
class ChannelMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
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.firstOrNull() == "e" && (it.size < 3 || (it.size > 3 && it[3] != "root")) }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun replyTos() = tags.filter { it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 42
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))
}
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).toHexKey()
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)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
}
}
@@ -1,46 +1,44 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.ContactMetaData
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
import java.util.Date
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
class ChannelMetadataEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun 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)
}
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
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).toHexKey()
val tags = listOf(listOf("e", originalChannelIdHex, "", "root"))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
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)
}
}
}
}
@@ -1,39 +1,35 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
import java.util.Date
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>
class ChannelMuteUserEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
init {
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 44
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()
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
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)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
}
}
@@ -0,0 +1,86 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
data class Contact(val pubKeyHex: String, val relayUri: String?)
class ContactListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
}.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
} else {
null
}
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
null
}
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
""
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = follows.map {
if (it.relayUri != null) {
listOf("p", it.pubKeyHex, it.relayUri)
} else {
listOf("p", it.pubKeyHex)
}
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
data class ReadWrite(val read: Boolean, val write: Boolean)
}
@@ -0,0 +1,30 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class DeletionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun deleteEvents() = tags.map { it[1] }
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = deleteEvents.map { listOf("e", it) }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return DeletionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,218 @@
package com.vitorpamplona.amethyst.service.model
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
import nostr.postr.Utils
import nostr.postr.toHex
import java.lang.reflect.Type
import java.security.MessageDigest
import java.util.*
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@SerializedName("created_at") val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: HexKey
) : EventInterface {
override fun id(): HexKey = id
override fun pubKey(): HexKey = pubKey
override fun createdAt(): Long = createdAt
override fun kind(): Int = kind
override fun tags(): List<List<String>> = tags
override fun content(): String = content
override fun sig(): HexKey = sig
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
override fun checkSignature() {
if (!id.contentEquals(generateId())) {
throw Exception(
"""|Unexpected ID.
| Event: ${toJson()}
| Actual ID: $id
| Generated: ${generateId()}
""".trimIndent()
)
}
if (!secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
throw Exception("""Bad signature!""")
}
}
override fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
}
private fun generateId(): String {
val rawEvent = listOf(0, pubKey, createdAt, kind, tags, content)
// GSON decided to hardcode these replacements.
// They break Nostr's hash check.
// These lines revert their code.
// https://github.com/google/gson/issues/2295
val rawEventJson = gson.toJson(rawEvent)
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): Event {
val jsonObject = json.asJsonObject
return Event(
id = jsonObject.get("id").asString,
pubKey = jsonObject.get("pubkey").asString,
createdAt = jsonObject.get("created_at").asLong,
kind = jsonObject.get("kind").asInt,
tags = jsonObject.get("tags").asJsonArray.map {
it.asJsonArray.mapNotNull { s -> if (s.isJsonNull) null else s.asString }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
)
}
}
private class EventSerializer : JsonSerializer<Event> {
override fun serialize(
src: Event,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("id", src.id)
addProperty("pubkey", src.pubKey)
addProperty("created_at", src.createdAt)
addProperty("kind", src.kind)
add(
"tags",
JsonArray().also { jsonTags ->
src.tags.forEach { tag ->
jsonTags.add(
JsonArray().also { jsonTagElement ->
tag.forEach { tagElement ->
jsonTagElement.add(tagElement)
}
}
)
}
}
)
addProperty("content", src.content)
addProperty("sig", src.sig)
}
}
}
private class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ByteArray = Hex.decode(json.asString)
}
private class ByteArraySerializer : JsonSerializer<ByteArray> {
override fun serialize(
src: ByteArray,
typeOfSrc: Type?,
context: JsonSerializationContext?
) = JsonPrimitive(src.toHex())
}
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
else -> this
}
fun generateId(pubKey: HexKey, createdAt: Long, kind: Int, tags: List<List<String>>, content: String): ByteArray {
val rawEvent = listOf(
0,
pubKey,
createdAt,
kind,
tags,
content
)
val rawEventJson = gson.toJson(rawEvent)
return sha256.digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey).toHexKey()
return Event(id.toHexKey(), pubKey, createdAt, kind, tags, content, sig)
}
}
}
@@ -0,0 +1,29 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
interface EventInterface {
fun id(): HexKey
fun pubKey(): HexKey
fun createdAt(): Long
fun kind(): Int
fun tags(): List<List<String>>
fun content(): String
fun sig(): HexKey
fun toJson(): String
fun checkSignature()
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
}
@@ -0,0 +1,71 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : LnZapEventInterface, Event(id, pubKey, createdAt, kind, tags, content, sig) {
override fun zappedPost() = tags
.filter { it.firstOrNull() == "e" }
.mapNotNull { it.getOrNull(1) }
override fun zappedAuthor() = tags
.filter { it.firstOrNull() == "p" }
.mapNotNull { it.getOrNull(1) }
override fun taggedAddresses(): List<ATag> = tags
.filter { it.firstOrNull() == "a" }
.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
override fun amount(): BigDecimal? {
return amount
}
// Keeps this as a field because it's a heavier function used everywhere.
val amount by lazy {
try {
lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse LnInvoice ${description()}", e)
null
}
}
override fun containedPost(): Event? = try {
description()?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.e("LnZapEvent", "Failed to Parse Contained Post ${description()}", e)
null
}
private fun lnInvoice(): String? = tags
.filter { it.firstOrNull() == "bolt11" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
private fun description(): String? = tags
.filter { it.firstOrNull() == "description" }
.mapNotNull { it.getOrNull(1) }
.firstOrNull()
companion object {
const val kind = 9735
}
}
@@ -0,0 +1,16 @@
package com.vitorpamplona.amethyst.service.model
import java.math.BigDecimal
interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>
fun zappedAuthor(): List<String>
fun taggedAddresses(): List<ATag>
fun amount(): BigDecimal?
fun containedPost(): Event?
}
@@ -0,0 +1,101 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 9734
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
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.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
/*
{
"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"
]
]
}
*/
@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
class LongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun summary() = tags.filter { it.firstOrNull() == "summary" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun publishedAt() = try {
tags.filter { it.firstOrNull() == "published_at" }.mapNotNull { it.getOrNull(1) }.firstOrNull()?.toLong()
} catch (_: Exception) {
null
}
companion object {
const val kind = 30023
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return LongTextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -0,0 +1,182 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?
)
abstract class IdentityClaim(
var identity: String,
var proof: String
) {
abstract fun toProofUrl(): String
abstract fun toIcon(): Int
abstract fun toDescriptor(): Int
abstract fun platform(): String
fun platformIdentity() = "${platform()}:$identity"
companion object {
fun create(platformIdentity: String, proof: String): IdentityClaim? {
val (platform, identity) = platformIdentity.split(':')
return when (platform.lowercase()) {
GitHubIdentity.platform -> GitHubIdentity(identity, proof)
TwitterIdentity.platform -> TwitterIdentity(identity, proof)
TelegramIdentity.platform -> TelegramIdentity(identity, proof)
MastodonIdentity.platform -> MastodonIdentity(identity, proof)
else -> throw IllegalArgumentException("Platform $platform not supported")
}
}
}
}
class GitHubIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://gist.github.com/$identity/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.github
override fun toDescriptor() = R.string.github
companion object {
val platform = "github"
fun parseProofUrl(proofUrl: String): GitHubIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://gist.github.com/").split("?")[0].split("/")
GitHubIdentity(path[0], path[1])
} catch (e: Exception) {
null
}
}
}
}
class TwitterIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://twitter.com/$identity/status/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.twitter
override fun toDescriptor() = R.string.twitter
companion object {
val platform = "twitter"
fun parseProofUrl(proofUrl: String): TwitterIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://twitter.com/").split("?")[0].split("/")
TwitterIdentity(path[0], path[2])
} catch (e: Exception) {
null
}
}
}
}
class TelegramIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://t.me/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.telegram
override fun toDescriptor() = R.string.telegram
companion object {
val platform = "telegram"
}
}
class MastodonIdentity(
identity: String,
proof: String
) : IdentityClaim(identity, proof) {
override fun toProofUrl() = "https://$identity/$proof"
override fun platform() = platform
override fun toIcon() = R.drawable.mastodon
override fun toDescriptor() = R.string.mastodon
companion object {
val platform = "mastodon"
fun parseProofUrl(proofUrl: String): MastodonIdentity? {
return try {
if (proofUrl.isBlank()) return null
val path = proofUrl.removePrefix("https://").split("?")[0].split("/")
return MastodonIdentity("${path[0]}/${path[1]}", path[2])
} catch (e: Exception) {
null
}
}
}
}
class MetadataEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java)
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e)
null
}
fun identityClaims() = tags.filter { it.firstOrNull() == "i" }.mapNotNull {
try {
IdentityClaim.create(it.get(1), it.get(2))
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse identity [${it.joinToString { "," }}]", e)
null
}
}
companion object {
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
identities.forEach {
tags.add(listOf("i", it.platformIdentity(), it.proof))
}
val id = generateId(pubKey, createdAt, kind, tags, contactMetaData)
val sig = Utils.sign(id, privateKey)
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, contactMetaData, sig.toHexKey())
}
}
}
@@ -0,0 +1,87 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
class PrivateDmEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
/**
* This may or may not be the actual recipient's pub key. The event is intended to look like a
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.firstOrNull() == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*
* Nip-18 messages should refer to other events by inline references in the content like
* `[](e/c06f795e1234a9a1aecc731d768d4f3ca73e80031734767067c82d67ce82e506).
*/
fun replyTo() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
val retVal = Utils.decrypt(content, sharedSecret)
if (retVal.startsWith(nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
Log.w("PrivateDM", "Error decrypting the message ${e.message}")
null
}
}
companion object {
const val kind = 4
const val nip18Advertisement = "[//]: # (nip18)\n"
fun create(
recipientPubKey: ByteArray,
msg: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
privateKey,
recipientPubKey
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
publishedRecipientPubKey?.let {
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
}
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 PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -1,40 +1,50 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
import java.util.Date
class ReactionEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
class ReactionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val originalPost: List<String>
@Transient val originalAuthor: List<String>
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
init {
originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 7
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
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)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id, pubKey, createdAt, tags, content, sig)
companion object {
const val kind = 7
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
if (originalNote is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
}
}
@@ -0,0 +1,37 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.net.URI
import java.util.Date
class RecommendRelayEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey,
val lenient: Boolean = false
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun relay() = if (lenient) {
URI.create(content.trim())
} else {
URI.create(content)
}
companion object {
const val kind = 2
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
val content = relay.toString()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RecommendRelayEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,103 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
class ReportEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
private fun defaultReportType(): ReportType {
// Works with old and new structures for report.
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
}
if (reportType == null) {
reportType = ReportType.SPAM
}
return reportType
}
fun reportedPost() = tags
.filter { it.firstOrNull() == "e" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
)
}
fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
)
}
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
const val kind = 1984
fun create(
reportedPost: EventInterface,
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags: List<List<String>> = listOf(reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
enum class ReportType() {
EXPLICIT, // Not used anymore.
ILLEGAL,
SPAM,
IMPERSONATION,
NUDITY,
PROFANITY
}
}
@@ -1,41 +1,54 @@
package com.vitorpamplona.amethyst.service.model
import java.util.Date
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
import java.util.Date
class RepostEvent (
id: ByteArray,
pubKey: ByteArray,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
class RepostEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
init {
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
companion object {
const val kind = 6
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = ""
val replyToPost = listOf("e", boostedPost.id.toHex())
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = boostedPost.tags.plus(listOf(replyToPost, replyToAuthor))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RepostEvent(id, pubKey, createdAt, tags, content, sig)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
}
}
fun containedPost() = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
companion object {
const val kind = 6
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 1
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -0,0 +1,18 @@
package com.vitorpamplona.amethyst.service.model.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
if (zaps == null) return emptyList()
return (
zaps
.filter { it.value != null }
.toList()
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
.reversed()
) as List<Pair<Note, Note>>
}
}
@@ -0,0 +1,104 @@
package com.vitorpamplona.amethyst.service.nip19
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
object Nip19 {
enum class Type {
USER, NOTE, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String, val relay: String? = null)
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:") ?: return null
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
}
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 $uri: ${e.message}")
}
return null
}
private fun npub(bytes: ByteArray): Return {
return Return(Type.USER, bytes.toHexKey())
}
private fun note(bytes: ByteArray): Return {
return Return(Type.NOTE, bytes.toHexKey())
}
private fun nprofile(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
}
private fun nevent(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val hex = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
return Return(Type.USER, hex, relay)
}
private fun nrelay(bytes: ByteArray): Return? {
val relayUrl = Tlv.parse(bytes)
.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
return Return(Type.RELAY, relayUrl)
}
private fun naddr(bytes: ByteArray): Return? {
val tlv = Tlv.parse(bytes)
val d = tlv.get(Tlv.Type.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
val relay = tlv.get(Tlv.Type.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(Tlv.Type.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay)
}
}
@@ -0,0 +1,36 @@
package com.vitorpamplona.amethyst.service.nip19
import java.nio.ByteBuffer
import java.nio.ByteOrder
object Tlv {
enum class Type(val id: Byte) {
SPECIAL(0),
RELAY(1),
AUTHOR(2),
KIND(3);
}
fun toInt32(bytes: ByteArray): Int {
require(bytes.size == 4) { "length must be 4, got: ${bytes.size}" }
return ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.BIG_ENDIAN).int
}
fun parse(data: ByteArray): Map<Byte, List<ByteArray>> {
val result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
val t = rest[0]
val l = rest[1]
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
rest = rest.sliceArray(IntRange(2 + l, rest.size - 1))
if (v.size < l) continue
if (!result.containsKey(t)) {
result[t] = mutableListOf()
}
result[t]?.add(v)
}
return result
}
}
@@ -1,18 +1,19 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.JsonFilter
import nostr.postr.events.Event
/**
* The Nostr Client manages multiple personae the user may switch between. Events are received and
* published through multiple relays.
* Events are stored with their respective persona.
*/
object Client: RelayPool.Listener {
object Client : RelayPool.Listener {
/**
* Lenient mode:
*
@@ -24,39 +25,52 @@ object Client: RelayPool.Listener {
**/
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
fun connect(
relays: Array<Relay> = Constants.defaultRelays
) {
@Synchronized
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
this.relays = relays
}
fun isSameRelaySetConfig(newRelayConfig: Array<Relay>): Boolean {
if (relays.size != newRelayConfig.size) return false
relays.forEach { oldRelayInfo ->
val newRelayInfo = newRelayConfig.find { it.url == oldRelayInfo.url } ?: return false
if (!oldRelayInfo.isSameRelayConfig(newRelayInfo)) return false
}
return true
}
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(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: List<JsonFilter> = listOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
fun send(signedEvent: Event) {
fun send(signedEvent: EventInterface) {
RelayPool.send(signedEvent)
}
fun close(subscriptionId: String){
fun close(subscriptionId: String) {
RelayPool.close(subscriptionId)
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
@@ -65,20 +79,40 @@ object Client: RelayPool.Listener {
RelayPool.unloadRelays()
}
@OptIn(DelicateCoroutinesApi::class)
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) }
}
}
@OptIn(DelicateCoroutinesApi::class)
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) }
@OptIn(DelicateCoroutinesApi::class)
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) }
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
listeners.forEach { it.onSendResponse(eventId, success, message, 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) {
@@ -90,12 +124,10 @@ object Client: RelayPool.Listener {
}
fun allSubscriptions(): List<String> {
return synchronized(subscriptions) {
subscriptions.keys.toList()
}
return subscriptions.keys.toList()
}
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
return subscriptions[subId] ?: emptyList()
}
@@ -113,11 +145,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
}
}
}
@@ -0,0 +1,54 @@
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)
val activeTypesSearch = setOf(FeedType.SEARCH)
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),
// Supporting NIP-50
RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
)
val forcedRelayForSearch = RelaySetupInfo("wss://relay.nostr.band", read = true, write = false, feedTypes = activeTypesSearch)
}
@@ -0,0 +1,143 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.vitorpamplona.amethyst.service.model.Event
import java.io.Serializable
import java.util.*
interface Filter {
fun match(event: Event): Boolean
fun toShortString(): String
}
class JsonFilter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null
) : Filter, Serializable {
fun toJson(): String {
val jsonObject = JsonObject()
ids?.run {
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
}
authors?.run {
jsonObject.add("authors", JsonArray().apply { authors.forEach { add(it) } })
}
kinds?.run {
jsonObject.add("kinds", JsonArray().apply { kinds.forEach { add(it) } })
}
tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
}
}
since?.run {
jsonObject.addProperty("since", since)
}
until?.run {
jsonObject.addProperty("until", until)
}
limit?.run {
jsonObject.addProperty("limit", limit)
}
search?.run {
jsonObject.addProperty("search", search)
}
return gson.toJson(jsonObject)
}
override fun match(event: Event): Boolean {
if (ids?.any { event.id == it } == false) return false
if (kinds?.any { event.kind == it } == false) return false
if (authors?.any { event.pubKey == it } == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
}
return true
}
override fun toString(): String = "JsonFilter${toJson()}"
override fun toShortString(): String {
val list = ArrayList<String>()
ids?.run {
list.add("ids")
}
authors?.run {
list.add("authors")
}
kinds?.run {
list.add("kinds[${kinds.joinToString()}]")
}
tags?.run {
list.add("tags")
}
since?.run {
list.add("since")
}
until?.run {
list.add("until")
}
limit?.run {
list.add("limit")
}
search?.run {
list.add("search")
}
return list.joinToString()
}
companion object {
val gson: Gson = GsonBuilder().create()
fun fromJson(json: String): JsonFilter {
val jsonFilter = gson.fromJson(json, JsonObject::class.java)
return fromJson(jsonFilter)
}
val declaredFields = JsonFilter::class.java.declaredFields.map { it.name }
fun fromJson(json: JsonObject): JsonFilter {
// sanity check
if (json.keySet().any { !(it.startsWith("#") || it in declaredFields) }) {
println("Filter $json contains unknown parameters.")
}
return JsonFilter(
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
authors = if (json.has("authors")) {
json.getAsJsonArray("authors")
.map { it.asString }
} else {
null
},
kinds = if (json.has("kinds")) {
json.getAsJsonArray("kinds")
.map { it.asInt }
} else {
null
},
tags = json
.entrySet()
.filter { it.key.startsWith("#") }
.associate {
it.key.substring(1) to it.value.asJsonArray.map { it.asString }
}
.ifEmpty { null },
since = if (json.has("since")) json.get("since").asLong else null,
until = if (json.has("until")) json.get("until").asLong else null,
limit = if (json.has("limit")) json.get("limit").asInt else null,
search = if (json.has("search")) json.get("search").asString else null
)
}
}
}
@@ -1,21 +1,42 @@
package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.Date
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
}
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 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 spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
var closingTime = 0L
fun register(listener: Listener) {
listeners = listeners.plus(listener)
@@ -29,118 +50,183 @@ class Relay(
return socket != null
}
@Synchronized
fun requestAndWatch() {
println("Connecting with ${url}")
val request = Request.Builder().url(url).build()
val listener = object : WebSocketListener() {
if (socket != null) return
override fun onOpen(webSocket: WebSocket, response: Response) {
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
try {
val request = Request.Builder()
.header("User-Agent", "Amethyst")
.url(url.trim())
.build()
val listener = object : WebSocketListener() {
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 {
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
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?) {
// Failures disconnect the relay.
socket = null
//println("Relay onFailure ${url}, ${response?.message}")
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()
// httpClient.dispatcher.executorService.shutdown()
closingTime = Date().time / 1000
socket?.close(1000, "Normal close")
socket = null
isReady = false
}
fun sendFilter(requestId: String) {
if (socket == null) {
requestAndWatch()
} else {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket!!.send(request)
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)
}
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
// sends all filters after connection is successful.
requestAndWatch()
}
}
}
}
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
requestAndWatch()
// 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)
fun send(signedEvent: EventInterface) {
if (write) {
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
eventUploadCounter++
}
}
fun close(subscriptionId: String){
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,
// Websocket disconnecting
DISCONNECTING,
// Websocket disconnected
DISCONNECT,
// End Of Stored Events
EOSE
}
@@ -154,11 +240,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?)
}
}
@@ -1,18 +1,20 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool: Relay.Listener {
object RelayPool : Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -24,11 +26,15 @@ object RelayPool: Relay.Listener {
return relays.filter { it.isConnected() }.size
}
fun loadRelays(relayList: List<Relay>? = null){
if (!relayList.isNullOrEmpty()){
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) }
}
}
@@ -49,11 +55,11 @@ object RelayPool: Relay.Listener {
relays.forEach { it.sendFilterOnlyIfDisconnected() }
}
fun send(signedEvent: Event) {
fun send(signedEvent: EventInterface) {
relays.forEach { it.send(signedEvent) }
}
fun close(subscriptionId: String){
fun close(subscriptionId: String) {
relays.forEach { it.close(subscriptionId) }
}
@@ -84,12 +90,11 @@ 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
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
@@ -99,8 +104,8 @@ 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()
}
@@ -112,17 +117,16 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}
}
}
class RelayPoolLiveData(val relays: RelayPool): LiveData<RelayPoolState>(RelayPoolState(relays)) {
class RelayPoolLiveData(val relays: RelayPool) : LiveData<RelayPoolState>(RelayPoolState(relays)) {
fun refresh() {
postValue(RelayPoolState(relays))
}
}
class RelayPoolState(val relays: RelayPool)
class RelayPoolState(val relays: RelayPool)
@@ -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
}
}
@@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
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.lowercase()) } }
}
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
}
}
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
}
}
@@ -2,88 +2,95 @@ package com.vitorpamplona.amethyst.ui
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.KeyStorage
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.NostrNotificationDataSource
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.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.nip19.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 kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
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
}
add(SvgDecoder.Factory())
}.build()
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()
}
LocalPreferences.migrateSingleUserPrefs()
setContent {
AmethystTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel()
}
AccountScreen(accountStateViewModel, startingPage)
}
}
}
Client.lenient = true
}
setContent {
AmethystTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
val accountViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
}
AccountScreen(accountViewModel)
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
}
}
}
Client.lenient = true
}
override fun onPause() {
ServiceManager.pause()
override fun onResume() {
super.onResume()
Client.connect()
}
super.onPause()
}
override fun onPause() {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
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()
}
}
@@ -0,0 +1,141 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
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()
.header("User-Agent", "Amethyst")
.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.
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}
@@ -1,57 +1,77 @@
package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
object ImageUploader {
private fun encodeImage(bitmap: Bitmap): ByteArray {
val byteArrayOutPutStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, byteArrayOutPutStream)
return byteArrayOutPutStream.toByteArray()
}
fun uploadImage(
uri: Uri,
contentResolver: ContentResolver,
onSuccess: (String) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
fun uploadImage(bitmap: Bitmap, onSuccess: (String) -> Unit) {
val client = OkHttpClient.Builder().build()
val client = OkHttpClient.Builder().build()
val body: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}.png",
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
)
.build()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(body)
.build()
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val tree = jacksonObjectMapper().readTree(response.body!!.string())
val url = tree.get("data").get("link").asText()
onSuccess(url)
}
}
imageInputStream.source().use(sink::writeAll)
}
}
)
.build()
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
}
})
}
}
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
.header("User-Agent", "Amethyst")
.url("https://api.imgur.com/3/image")
.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)
}
})
}
}
@@ -8,6 +8,7 @@ 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
@@ -15,21 +16,21 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
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, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) {
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
val postViewModel: NewChannelViewModel = viewModel()
postViewModel.load(account, channel, accountStateViewModel)
postViewModel.load(account, channel)
Dialog(
onDismissRequest = { onClose() },
@@ -37,8 +38,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
dismissOnClickOutside = false
)
) {
Surface(
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp)
) {
@@ -65,25 +65,26 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Channel Name") },
label = { Text(text = stringResource(R.string.channel_name)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelName.value,
onValueChange = { postViewModel.channelName.value = it },
placeholder = {
Text(
text = "My Awesome Group",
text = stringResource(R.string.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") },
label = { Text(text = stringResource(R.string.picture_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.channelPicture.value,
onValueChange = { postViewModel.channelPicture.value = it },
@@ -98,26 +99,24 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = "Description") },
label = { Text(text = stringResource(R.string.description)) },
modifier = Modifier.fillMaxWidth().height(100.dp),
value = postViewModel.channelDescription.value,
onValueChange = { postViewModel.channelDescription.value = it },
placeholder = {
Text(
text = "About us.. ",
text = stringResource(R.string.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
)
}
}
}
}

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