Compare commits

...
424 Commits
Author SHA1 Message Date
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
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
greenart7c3 3a7228712c fix dropDown color on light theme 2023-02-06 17:30:35 -03:00
213 changed files with 12822 additions and 3482 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.
Generated
+1
View File
@@ -0,0 +1 @@
Amethyst
+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>
+1 -1
View File
@@ -1,7 +1,7 @@
<?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">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+38 -16
View File
@@ -6,27 +6,49 @@ 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 writing
- [ ] 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)
- [ ] 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)
- [ ] Internationalization
- [ ] Infinity Scroll
- [ ] Badges (NIP-58)
- [ ] 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
@@ -123,4 +145,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.
+46 -20
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 38
versionName "0.13.2"
versionCode 89
versionName "0.24.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,7 +22,7 @@ android {
buildTypes {
release {
//minifyEnabled false
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
@@ -43,7 +43,7 @@ android {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.0'
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
@@ -54,27 +54,33 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
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"
// Needs this to open gallery / image upload
implementation "androidx.fragment:fragment-ktx:$fragment_version"
// Navigation
implementation("androidx.navigation:navigation-compose:$nav_version")
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
implementation 'androidx.compose.material:material:1.4.0-alpha05'
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
implementation 'androidx.compose.material:material:1.4.0-beta02'
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
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"
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
@@ -86,19 +92,24 @@ dependencies {
exclude module: 'guava-testlib'
}
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// Websockets API
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
// 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'
// Robohash for Avatars
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// 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"
@@ -116,21 +127,36 @@ dependencies {
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 "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
// 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"
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
// Local Barcode Scanning model
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
// The idea is to make it work for degoogled phones
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
// Use this dependency to use the translate text
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+8 -2
View File
@@ -25,7 +25,13 @@
-keep class fr.acinq.secp256k1.jni.** { *; }
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
-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.service.** { *; }
-keep class com.vitorpamplona.amethyst.ui.** { *; }
@@ -0,0 +1,24 @@
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()
}
}
@@ -0,0 +1,78 @@
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"
)
}
}
@@ -5,6 +5,7 @@ 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
@@ -29,7 +30,8 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
@@ -63,7 +65,8 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
+5 -5
View File
@@ -4,8 +4,13 @@
<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="false"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -19,7 +24,6 @@
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:label="@string/app_name"
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
@@ -41,10 +45,6 @@
android:name="android.app.lib_name"
android:value="" />
</activity>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
</application>
</manifest>
@@ -4,75 +4,119 @@ import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import com.vitorpamplona.amethyst.ui.navigation.Route
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
val gson = GsonBuilder().create()
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
remove("nostr_privkey")
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
remove("relays")
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>()
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays
)
} else {
return null
}
private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
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"
val LAST_READ: (String) -> String = { route -> "last_read_route_${route}" }
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
putLong("last_read_route_${route}", timestampInSecs)
}.apply()
}
private val encryptedPreferences = EncryptedStorage().preferences(context)
private val gson = GsonBuilder().create()
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
return getLong("last_read_route_${route}", 0)
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
encryptedPreferences.all.keys.forEach { remove(it) }
}.apply()
}
fun saveToEncryptedStorage(account: Account) {
encryptedPreferences.edit().apply {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences.apply {
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, 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<String,String>()
} catch (e: Throwable) {
e.printStackTrace()
mapOf<String,String>()
}
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
latestContactList
)
} else {
return null
}
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences.edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences.run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
}
}
@@ -2,12 +2,14 @@ package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.model.Note
import java.util.concurrent.atomic.AtomicBoolean
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
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
@@ -20,7 +22,7 @@ object NotificationCache {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
invalidateData()
live.invalidateData()
}
}
}
@@ -36,24 +38,29 @@ object NotificationCache {
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
// 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))
}
@@ -0,0 +1,169 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.util.UUID
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
import name.neuhalfen.projects.android.robohash.handle.Handle
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
import name.neuhalfen.projects.android.robohash.paths.Configuration
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
object RoboHashCache {
lateinit var robots: MyRoboHash
lateinit var defaultAvatar: ImageBitmap
@Synchronized
fun get(context: Context, hash: String): ImageBitmap {
if (!this::robots.isInitialized) {
robots = MyRoboHash(context)
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
}
return defaultAvatar
}
}
/**
* Recreates RoboHash to use a custom configuration
*/
class MyRoboHash(context: Context) {
private val configuration: Configuration = ModifiedSet1Configuration()
private val repository: ImageRepository
private val hashing = VariableSizeHashing(configuration.bucketSizes)
// Optional
private var memoryCache: LruCache<String, Bitmap>? = null
init {
repository = ImageRepository(context.assets)
}
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
this.memoryCache = memoryCache
}
fun calculateHandleFromUUID(uuid: UUID?): Handle {
val data = hashing.createBuckets(uuid)
return handleFactory.calculateHandle(data)
}
fun imageForHandle(handle: Handle): Bitmap {
if (null != memoryCache) {
val cached = memoryCache!![handle.toString()]
if (null != cached) return cached
}
val bucketValues = handle.bucketValues()
val paths = configuration.convertToFacetParts(bucketValues)
val sampleSize = 1
val buffer = repository.createBuffer(configuration.width(), configuration.height())
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
val merged = Canvas(target)
val paint = Paint(0)
// The first image is not added as copy form the buffer
for (i in paths.indices) {
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
}
repository.returnBuffer(buffer)
if (null != memoryCache) {
memoryCache!!.put(handle.toString(), target)
}
return target
}
companion object {
private val handleFactory = HandleFactory()
}
}
/**
* Custom configuration to avoid the use of String.format in the GeneratePath
* This uses the default location and ends up encoding number in the local language
*/
class ModifiedSet1Configuration : Configuration {
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
require(bucketValues.size == BUCKET_COUNT)
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
val paths = mutableListOf<String>()
// e.g.
// blue face #2
// blue nose #7
// blue
val firstFacetBucket = BUCKET_COLOR + 1
for (facet in 0 until FACET_COUNT) {
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
}
return paths.toTypedArray()
}
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
// TODO: Make more efficient
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
}
override fun getBucketSizes(): ByteArray {
return BUCKET_SIZES
}
override fun width(): Int {
return 300
}
override fun height(): Int {
return 300
}
companion object {
private const val ROOT = "sets/set1"
private const val BUCKET_COLOR = 0
private const val COLOR_COUNT = 10
private const val BODY_COUNT = 10
private const val FACE_COUNT = 10
private const val MOUTH_COUNT = 10
private const val EYES_COUNT = 10
private const val ACCESSORY_COUNT = 10
private const val BUCKET_COUNT = 6
private const val FACET_COUNT = 5
private val BUCKET_SIZES = byteArrayOf(
COLOR_COUNT.toByte(),
BODY_COUNT.toByte(),
FACE_COUNT.toByte(),
MOUTH_COUNT.toByte(),
EYES_COUNT.toByte(),
ACCESSORY_COUNT.toByte()
)
private val INT_TO_COLOR = arrayOf(
"blue",
"brown",
"green",
"grey",
"orange",
"pink",
"purple",
"red",
"white",
"yellow"
)
private val FACET_PATH_TEMPLATES = arrayOf(
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
)
}
}
@@ -1,21 +1,19 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.Constants
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.NostrNotificationDataSource
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.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null
@@ -29,30 +27,22 @@ object ServiceManager {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.convertLocalRelays())
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrNotificationDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrGlobalDataSource.account = myAccount
NostrChannelDataSource.account = myAccount
NostrUserProfileDataSource.account = myAccount
NostrUserProfileFollowsDataSource.account = myAccount
NostrUserProfileFollowersDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrGlobalDataSource.start()
NostrHomeDataSource.start()
NostrNotificationDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
NostrThreadDataSource.start()
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
@@ -65,17 +55,25 @@ object ServiceManager {
NostrChannelDataSource.stop()
NostrChatroomListDataSource.stop()
NostrUserProfileDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrGlobalDataSource.stop()
NostrNotificationDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
Client.disconnect()
}
fun cleanUp() {
LocalCache.cleanObservers()
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
//LocalCache.pruneNonFollows(it)
}
}
}
@@ -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,45 +1,70 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.Contact
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.Contact
import kotlinx.coroutines.withContext
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
val DefaultChannels = setOf(
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { codedList.add(it.language) }
}
return codedList
}
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet()
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 backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
@@ -50,7 +75,7 @@ class Account(
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
@@ -60,41 +85,56 @@ class Account(
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows,
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
relays,
loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event)
LocalCache.consume(event)
Client.send(event)
LocalCache.consume(event)
} else {
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) {
if (!isWriteable()) return
loggedIn.privKey?.let {
val createdAt = Date().time / 1000
val content = toString
val pubKey = Utils.pubkeyCreate(it)
val tags = listOf<List<String>>()
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
val sig = Utils.sign(id, it)
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
val event = MetadataEvent.create(toString, 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 (note.hasReacted(userProfile(), "+")) {
if (hasReacted(note)) {
// has already liked this note
return
}
@@ -106,6 +146,26 @@ class Account(
}
}
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
if (!isWriteable()) return null
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().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().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
@@ -123,7 +183,7 @@ class Account(
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
LocalCache.consume(event, null)
}
}
@@ -137,13 +197,29 @@ class Account(
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(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.hasBoosted(userProfile())) {
if (note.hasBoostedInTheLast5Minutes(userProfile())) {
// has already bosted in the past 5mins
return
}
@@ -164,10 +240,12 @@ class Account(
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
@@ -186,12 +264,15 @@ class Account(
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -202,18 +283,20 @@ class Account(
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 sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
if (!isWriteable()) return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -234,10 +317,15 @@ class Account(
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,
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -260,27 +348,40 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
joinChannel(event.id)
}
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
invalidateData()
live.invalidateData()
saveable.invalidateData()
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
invalidateData()
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData()
live.invalidateData()
saveable.invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
invalidateData()
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) {
@@ -299,7 +400,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
joinChannel(event.id.toHex())
joinChannel(event.id)
}
fun decryptContent(note: Note): String? {
@@ -307,36 +408,69 @@ class Account(
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey
val recepientPK = event.recipientPubKey()
if (note.author == userProfile() && recepientPK != null)
pubkeyToUse = recepientPK
return try {
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
val retVal = Utils.decrypt(event.content, sharedSecret)
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
retVal.substring(16)
} else {
retVal
}
} catch (e: Exception) {
e.printStackTrace()
null
}
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?.follows().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>? {
return userProfile().relays?.map {
var usersRelayList = userProfile().relays?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
}?.toTypedArray()
} ?: 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> {
@@ -345,52 +479,33 @@ class Account(
}.toTypedArray()
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
Client.disconnect()
Client.connect(activeRelays() ?: convertLocalRelays())
RelayPool.requestAndWatch()
}
})
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
fun reconnectIfRelaysHaveChanged() {
val newRelaySet = activeRelays() ?: convertLocalRelays()
if (!Client.isSameRelaySetConfig(newRelaySet)) {
Client.disconnect()
Client.connect(newRelaySet)
RelayPool.requestAndWatch()
}
}
fun isHidden(user: User) = user in hiddenUsers()
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
return !isHidden(user) // if user hasn't hided this author
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& user.reportsBy( userProfile().follows ).size < 5
&& user.countReportAuthorsBy( userProfile().follows ) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& note.reportsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
&& note.countReportAuthorsBy( userProfile().follows ) < 5 // if it has 5 reports by reliable users
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is RepostEvent
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
@@ -408,14 +523,68 @@ class Account(
innerReports).toSet()
}
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) {
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
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 !in userProfile().follows) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
}
}
}
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() {
postValue(AccountState(account))
}
@@ -0,0 +1,90 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import java.util.concurrent.atomic.AtomicBoolean
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 com.vitorpamplona.amethyst.service.model.Event
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,15 +1,13 @@
package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.concurrent.ConcurrentHashMap
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)
@@ -17,11 +15,14 @@ class Channel(val id: ByteArray) {
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayHex
return info.name ?: idDisplayNote()
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note
}
@@ -34,9 +35,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 {
@@ -47,8 +48,21 @@ 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
}
}
@@ -60,12 +74,12 @@ class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState
override fun onActive() {
super.onActive()
NostrSingleEventDataSource.add(channel.idHex)
NostrSingleChannelDataSource.add(channel.idHex)
}
override fun onInactive() {
super.onInactive()
NostrSingleEventDataSource.remove(channel.idHex)
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
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
@@ -41,8 +40,44 @@ fun decodePublicKey(key: String): ByteArray {
Persona(privKey = key.bechToBytes()).pubKey
} else if (key.startsWith("npub")) {
key.bechToBytes()
} else if (key.startsWith("note")) {
key.bechToBytes()
} else { //if (pattern.matcher(key).matches()) {
//} else {
Hex.decode(key)
}
}
data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
if (key.startsWith("nostr:", true)) {
key = key.substring("nostr:".length)
}
key = key.removePrefix("@")
if (key.length < 63)
return null
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
} else if (key.startsWith("npub1", true)) {
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
} else if (key.startsWith("note1", true)) {
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}
@@ -5,29 +5,43 @@ import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.ATag
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.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 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.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import fr.acinq.secp256k1.Hex
import java.io.ByteArrayInputStream
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import com.vitorpamplona.amethyst.service.relays.Relay
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 nostr.postr.toHex
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.toNpub
@@ -36,9 +50,22 @@ object LocalCache {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>()
fun checkGetOrCreateUser(key: String): User? {
return try {
val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex
getOrCreateUser(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
null
}
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
@@ -49,6 +76,19 @@ object LocalCache {
}
}
fun checkGetOrCreateNote(key: String): Note? {
if (key.startsWith("naddr1")) {
return checkGetOrCreateAddressableNote(key)
}
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateNote(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create note: $key", e)
null
}
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
@@ -58,31 +98,65 @@ object LocalCache {
}
}
fun checkGetOrCreateChannel(key: String): Channel? {
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateChannel(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
return channels[key] ?: run {
val answer = Channel(key.toByteArray())
val answer = Channel(key)
channels.put(key, answer)
answer
}
}
fun checkGetOrCreateAddressableNote(key: String): AddressableNote? {
return try {
val addr = ATag.parse(key)
if (addr != null)
getOrCreateAddressableNote(addr)
else
null
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
return addressables[key.toNAddr()] ?: run {
val answer = AddressableNote(key)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toNAddr(), answer)
answer
}
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldUser.updatedMetadataAt) {
val oldUser = getOrCreateUser(event.pubKey)
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
metadataParser.readValue(
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
return
}
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
oldUser.updateUserInfo(newUser, event)
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -94,21 +168,29 @@ object LocalCache {
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos.map { getOrCreateNote(it) }
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, replyTo)
@@ -133,30 +215,180 @@ object LocalCache {
refreshObservers()
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event?.id == event.id) return
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, mentions, replyTo)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
refreshObservers()
}
}
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event?.id == event.id) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
refreshObservers()
}
}
fun consume(event: BadgeProfilesEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event?.id == event.id) return
val replyTo = event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList(), replyTo)
author.updateAcceptedBadges(note)
refreshObservers()
}
}
fun consume(event: BadgeAwardEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardees, awardDefinition)
// Adds notifications to users.
awardees.forEach {
it.addTaggedPost(note)
}
// Counts the replies
awardees.forEach {
it.addBadgeAward(note)
}
// Replies of an Badge Definition are Award Events
awardDefinition.forEach {
it.addReply(note)
}
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
private fun replyToWithoutCitations(event: TextNoteEvent): List<String> {
val repliesTo = event.replyTos()
if (repliesTo.isEmpty()) return repliesTo
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo
} else {
repliesTo.filter { it !in citations }
}
}
private fun replyToWithoutCitations(event: LongTextNoteEvent): List<String> {
val repliesTo = event.replyTos()
if (repliesTo.isEmpty()) return repliesTo
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo
} else {
repliesTo.filter { it !in citations }
}
}
fun consume(event: RecommendRelayEvent) {
//Log.d("RR", event.toJson())
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey.toHexKey())
val user = getOrCreateUser(event.pubKey)
val follows = event.follows()
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
user.updateFollows(
event.follows.map {
follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println("UpdateFollows: " + event.toJson())
e.printStackTrace()
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
//e.printStackTrace()
null
}
}.filterNotNull().toSet(),
event.createdAt
)
// Saves relay list only if it's a user that is currently been seen
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
@@ -168,31 +400,32 @@ object LocalCache {
user.updateRelays(relays)
}
} catch (e: Exception) {
Log.w("Relay List Parser","Relay import issue ${e.message}", e)
e.printStackTrace()
}
user.latestContactList = event
Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(it) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -205,20 +438,55 @@ object LocalCache {
}
fun consume(event: DeletionEvent) {
//Log.d("DEL", event.toJson())
var deletedAtLeastOne = false
event.deleteEvents().mapNotNull { notes[it] }.forEach { deleteNote ->
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
deleteNote.mentions?.forEach { user ->
user.removeTaggedPost(deleteNote)
user.removeReport(deleteNote)
}
deleteNote.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(deleteNote)
}
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
masterNote.removeReply(deleteNote)
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeReport(deleteNote)
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
}
}
if (deletedAtLeastOne) {
live.invalidateData()
}
}
fun consume(event: RepostEvent) {
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -242,14 +510,15 @@ object LocalCache {
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id.toHexKey())
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -286,15 +555,21 @@ object LocalCache {
}
}
fun consume(event: ReportEvent) {
val note = getOrCreateNote(event.id.toHex())
fun consume(event: ReportEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.map { getOrCreateUser(it) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -313,15 +588,14 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
@@ -330,20 +604,21 @@ object LocalCache {
// older data, does nothing
}
}
fun consume(event: ChannelMetadataEvent) {
val channelId = event.channel()
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
if (event.channel.isNullOrBlank()) return
if (channelId.isNullOrBlank()) return
// new event
val oldChannel = getOrCreateChannel(event.channel)
val author = getOrCreateUser(event.pubKey.toHexKey())
val oldChannel = checkGetOrCreateChannel(channelId) ?: return
val author = getOrCreateUser(event.pubKey)
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
@@ -354,29 +629,37 @@ object LocalCache {
}
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
val channelId = event.channel()
val channel = getOrCreateChannel(event.channel)
if (channelId.isNullOrBlank()) return
val note = getOrCreateNote(event.id.toHex())
val channel = checkGetOrCreateChannel(channelId) ?: return
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey.toHexKey())
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
@@ -405,41 +688,242 @@ object LocalCache {
}
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val zapRequest = event.containedPost()?.id?.let { getOrCreateNote(it) }
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
note.loadEvent(event, author, mentions, repliesTo)
if (zapRequest == null) {
Log.e("ZP","Zap Request not found. Unable to process Zap {${event.toJson()}}")
return
}
//Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
mentions.forEach {
it.addZap(zapRequest, note)
}
}
fun consume(event: LnZapRequestEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
mentions.forEach {
it.addZap(note, null)
}
}
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter {
it.info.anyNameStartsWith(username)
(it.anyNameStartsWith(username))
|| it.pubkeyHex.startsWith(username, true)
|| it.pubkey.toNpub().startsWith(username, true)
|| it.pubkeyNpub().startsWith(username, true)
}
}
fun findNotesStartingWith(text: String): List<Note> {
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
|| it.idNote().startsWith(text, true)
} + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false
|| (it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false
|| (it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false
|| it.idHex.startsWith(text, true)
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
return channels.values.filter {
it.anyNameStartsWith(text)
it.anyNameStartsWith(text)
|| it.idHex.startsWith(text, true)
|| it.id.toNote().startsWith(text, true)
|| it.idNote().startsWith(text, true)
}
}
fun cleanObservers() {
notes.forEach {
it.value.clearLive()
}
users.forEach {
it.value.clearLive()
}
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach {
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { replyingNote ->
it.removeReply(it)
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
}.flatten()
account.hiddenUsers.forEach {
users[it]?.clearNotes()
}
toBeRemoved.forEach {
it.author?.removeNote(it)
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
}
notes.remove(it.idHex)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
private fun refreshObservers() {
live.refresh()
live.invalidateData()
}
}
class LocalCacheLiveData(val cache: LocalCache): LiveData<LocalCacheState>(LocalCacheState(cache)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(50)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(LocalCacheState(cache))
}
}
@@ -2,33 +2,44 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ATag
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.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Collections
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Date
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
import kotlinx.coroutines.withContext
import com.vitorpamplona.amethyst.service.model.Event
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
val id = Hex.decode(idHex)
val idDisplayNote = id.toNote().toShortenHex()
class AddressableNote(val address: ATag): Note(address.toNAddr()) {
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
@@ -43,23 +54,40 @@ class Note(val idHex: String) {
private set
var boosts = setOf<Note>()
private set
var reports = setOf<Note>()
var reports = mapOf<User, Set<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
var channel: Channel? = null
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, mentions: List<User>, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
live.invalidateData()
liveSet?.metadata?.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -67,63 +95,124 @@ 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 (note !in replies) {
replies = replies + note
liveReplies.invalidateData()
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 (note !in boosts) {
boosts = boosts + note
liveBoosts.invalidateData()
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 (note !in reactions) {
reactions = reactions + note
liveReactions.invalidateData()
liveSet?.reactions?.invalidateData()
}
}
fun addReport(note: Note) {
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
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
liveRelays.invalidateData()
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 {
return reactions.any { it.author == user }
}
@@ -132,18 +221,54 @@ class Note(val idHex: String) {
return boosts.any { it.author == user }
}
fun reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
fun reportsBy(user: User): Set<Note> {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
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 author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|| reports.isNotEmpty()
return reports.isNotEmpty() ||
(author?.reports?.values?.filter {
it.firstOrNull { ( it.createdAt() ?: 0 ) > dayAgo } != null
}?.isNotEmpty() ?: 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> {
@@ -153,7 +278,9 @@ class Note(val idHex: String) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(LocalCache.getOrCreateUser(tag[1]))
LocalCache.checkGetOrCreateUser(tag[1])?.let {
returningList.add(it)
}
}
} catch (e: Exception) {
@@ -173,38 +300,84 @@ class Note(val idHex: String) {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
return reactedBy(loggedIn, content).isNotEmpty()
}
fun hasBoosted(loggedIn: User): Boolean {
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.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
// Observers line up here.
val live: NoteLiveData = NoteLiveData(this)
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
val liveReactions: NoteLiveData = NoteLiveData(this)
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
val liveRelays: NoteLiveData = NoteLiveData(this)
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 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 = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
var handlerWaiting = AtomicBoolean()
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -214,12 +387,21 @@ class NoteLiveData(val note: Note): LiveData<NoteState>(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,76 @@
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.startsWith("naddr")) {
val aTag = ATag.parse(noteId)
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,7 +7,6 @@ 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
@@ -57,7 +56,7 @@ object UrlCachedPreviewer {
fun preloadPreviewsFor(note: Note) {
note.event?.content?.let {
findUrlsInMessage(it).forEach {
val removedParamsFromUrl = it.split("?")[0].toLowerCase()
val removedParamsFromUrl = it.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
// Preload Images? Isn't this too heavy?
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
@@ -70,4 +69,4 @@ object UrlCachedPreviewer {
}
}
}
}
}
@@ -2,35 +2,35 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.math.BigDecimal
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import nostr.postr.toNpub
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class Badges(val definition: Note, val awardees: Set<Note>)
class User(val pubkeyHex: String) {
val pubkey = Hex.decode(pubkeyHex)
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
var info: UserMetadata? = null
var info = UserMetadata()
var updatedMetadataAt: Long = 0;
var updatedFollowsAt: Long = 0;
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
var follows = setOf<User>()
private set
@@ -39,10 +39,16 @@ class User(val pubkeyHex: String) {
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = setOf<Note>()
var reports = mapOf<User, Set<Note>>()
private set
var latestReportTime: Long = 0
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
@@ -51,106 +57,203 @@ class User(val pubkeyHex: String) {
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
var messages = mapOf<User, Set<Note>>()
var privateChatrooms = mapOf<User, Chatroom>()
private set
var latestMetadataRequestEOSE: Long? = null
var latestReportRequestEOSE: Long? = null
var badgeAwards = setOf<Note>()
private set
var acceptedBadges: AddressableNote? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex()
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex()
}
fun bestUsername(): String? {
return info.name?.ifBlank { null } ?: info.username?.ifBlank { null }
return info?.name?.ifBlank { null } ?: info?.username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return info.displayName?.ifBlank { null } ?: info.display_name?.ifBlank { null }
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
}
fun profilePicture(): String {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
fun nip05(): String? {
return info?.nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
}
fun follow(user: User, followedAt: Long) {
follows = follows + user
user.followers = user.followers + this
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
it.onFollowsChange()
}
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveFollows.invalidateData()
user.liveFollows.invalidateData()
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
updateSubscribers {
it.onFollowsChange()
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
updateSubscribers { it.onNewTaggedPosts() }
// No need for Listener yet
}
}
fun removeTaggedPost(note: Note) {
taggedPosts = taggedPosts - note
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
updateSubscribers { it.onNewNotes() }
// No need for Listener yet
}
}
fun removeNote(note: Note) {
notes = notes - note
}
fun clearNotes() {
notes = setOf<Note>()
}
fun addReport(note: Note) {
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
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 reportsBy(user: User): List<Note> {
return reports.filter { it.author == user }
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 addBadgeAward(note: Note) {
if (note !in badgeAwards) {
badgeAwards = badgeAwards + note
liveSet?.badges?.invalidateData()
}
}
fun removeBadgeAward(deleteNote: Note) {
badgeAwards = badgeAwards - deleteNote
liveSet?.badges?.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<User>): List<User> {
return reports.keys.filter { it in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
}
fun reportsBy(users: Set<User>): List<Note> {
return reports.filter { it.author in users }
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
}
@Synchronized
fun getOrCreateChannel(user: User): Set<Note> {
return messages[user] ?: run {
val channel = setOf<Note>()
messages = messages + Pair(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) {
val channel = getOrCreateChannel(user)
if (msg !in channel) {
messages = messages + Pair(user, channel + msg)
liveMessages.invalidateData()
updateSubscribers { it.onNewMessage() }
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
liveSet?.messages?.invalidateData()
}
}
data class RelayInfo (
val url: String,
var lastEvent: Long,
var counter: Long
)
fun addRelay(relay: Relay, eventTime: 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))
@@ -161,42 +264,47 @@ class User(val pubkeyHex: String) {
here.counter++
}
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
toBeAdded.forEach {
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
}
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
data class RelayMetadata(val read: Boolean, val write: Boolean, val activeTypes: Set<FeedType>)
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
listeners.forEach {
it.onRelayChange()
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
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.
}
}
liveRelays.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
liveMetadata.invalidateData()
liveSet?.metadata?.invalidateData()
}
fun isFollowing(user: User): Boolean {
@@ -204,66 +312,71 @@ class User(val pubkeyHex: String) {
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = messages[user] ?: return false
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.firstOrNull { this == it.author } != null
return messagesToUser.roomMessages.any { this == it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports.firstOrNull {
it.author == loggedIn
&& it.event is ReportEvent
&& (it.event as ReportEvent).reportType.contains(type)
return reports[loggedIn]?.firstOrNull() {
it.event is ReportEvent && (it.event as ReportEvent).reportedAuthor().any { it.reportType == type }
} != null
}
// Model Observers
private var listeners = setOf<Listener>()
fun subscribe(listener: Listener) {
listeners = listeners.plus(listener)
fun anyNameStartsWith(username: String): Boolean {
return info?.anyNameStartsWith(username) ?: false
}
fun unsubscribe(listener: Listener) {
listeners = listeners.minus(listener)
var liveSet: UserLiveSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
return liveSet!!
}
abstract class Listener {
open fun onRelayChange() = Unit
open fun onFollowsChange() = Unit
open fun onNewTaggedPosts() = Unit
open fun onNewNotes() = Unit
open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
modelHandlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet = null
}
}
// UI Observers line up here.
val liveFollows: UserLiveData = UserLiveData(this)
val liveReports: UserLiveData = UserLiveData(this)
val liveMessages: UserLiveData = UserLiveData(this)
val liveRelays: UserLiveData = UserLiveData(this)
val liveRelayInfo: UserLiveData = UserLiveData(this)
val liveMetadata: UserLiveData = UserLiveData(this)
}
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
@@ -273,7 +386,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
@@ -283,26 +400,33 @@ 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)) {
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
var handlerWaiting = AtomicBoolean()
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -312,13 +436,14 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user.pubkeyHex)
NostrSingleUserDataSource.add(user)
}
override fun onInactive() {
super.onInactive()
NostrSingleUserDataSource.remove(user.pubkeyHex)
NostrSingleUserDataSource.remove(user)
}
}
class UserState(val user: User)
@@ -0,0 +1,93 @@
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(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(lnaddress, 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 = Request.Builder().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
)
}
}
@@ -2,62 +2,127 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.nio.ByteBuffer
import java.nio.ByteOrder
class Nip19 {
enum class Type {
USER, NOTE
USER, NOTE, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String)
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:")
val key = uri?.removePrefix("nostr:") ?: return null
if (key != null) {
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return Return(Type.USER, bytes.toHexKey())
}
if (key.startsWith("note")) {
return Return(Type.NOTE, bytes.toHexKey())
}
if (key.startsWith("nprofile")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
if (key.startsWith("nevent")) {
val tlv = parseTLV(bytes)
val hex = tlv.get(0)?.get(0)?.toHexKey()
if (hex != null)
return Return(Type.USER, hex)
}
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("Trying to Decode NIP19: ${uri}")
e.printStackTrace()
println("Issue trying to Decode NIP19 ${uri}: ${e.message}")
}
return null
}
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {
val t = rest[0]
val l = rest[1]
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
rest = rest.sliceArray(IntRange(2 + l, rest.size-1))
if (v.size < l) continue
if (!result.containsKey(t)) {
result.put(t, mutableListOf())
}
result.get(t)?.add(v)
}
return result
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 hex = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
return Return(Type.USER, hex)
}
private fun nevent(bytes: ByteArray): Return? {
val hex = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toHexKey() ?: return null
return Return(Type.USER, hex)
}
private fun nrelay(bytes: ByteArray): Return? {
val relayUrl = parseTLV(bytes)
.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
return Return(Type.RELAY, relayUrl)
}
private fun naddr(bytes: ByteArray): Return? {
val tlv = parseTLV(bytes)
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)
?.get(0)
?.toString(Charsets.UTF_8) ?: return null
val relay = tlv.get(NIP19TLVTypes.RELAY.id)
?.get(0)
?.toString(Charsets.UTF_8)
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)
?.get(0)
?.toHexKey()
val kind = tlv.get(NIP19TLVTypes.KIND.id)
?.get(0)
?.let { toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d")
}
}
// Classes should start with an uppercase letter in kotlin
enum class NIP19TLVTypes(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 parseTLV(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,21 @@
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.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
object NostrAccountDataSource: NostrDataSource("AccountData") {
lateinit var account: Account
fun createAccountContactListFilter(): TypedFilter {
@@ -37,6 +40,17 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
)
}
fun createAccountAcceptedAwardsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
@@ -50,6 +64,9 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
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
)
@@ -57,22 +74,14 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
val accountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter()
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter()
).ifEmpty { null }
}
}
@@ -1,16 +1,14 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.relays.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
lateinit var account: Account
var channel: com.vitorpamplona.amethyst.model.Channel? = null
object NostrChannelDataSource: NostrDataSource("ChatroomFeed") {
var channel: Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.getOrCreateChannel(channelId)
@@ -33,11 +31,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
val messagesChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
override fun updateChannelFilters() {
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
@@ -2,20 +2,20 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
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? {
@@ -40,7 +40,7 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
@@ -54,13 +54,6 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
val inandoutChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser] ?: return emptyList()
return messages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
@@ -1,16 +1,15 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = TypedFilter(
@@ -38,7 +37,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
)
fun createMyChannelsFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
@@ -48,7 +47,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
types = FeedType.values().toSet(), // Metadata comes from any relay
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
@@ -65,7 +64,7 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
limit = 100 // Remember to consider spam that is being removed from the UI
)
)
}
@@ -73,22 +72,6 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
val chatroomListChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys.filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
val list = listOf(
createMessagesToMeFilter(),
@@ -1,13 +1,18 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.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.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
@@ -16,69 +21,84 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import java.util.Date
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
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 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.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
abstract class NostrDataSource(val debugName: String) {
private var subscriptions = mapOf<String, Subscription>()
private var eventCounter = mapOf<String, Int>()
data class Counter(var counter:Int)
private var eventCounter = mapOf<String, Counter>()
fun printCounter() {
eventCounter.forEach {
println("AAA Count ${it.key}: ${it.value}")
println("AAA Count ${it.key}: ${it.value.counter}")
}
}
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "${debugName} ${subscriptionId} ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
eventCounter = eventCounter + Pair(key, keyValue + 1)
keyValue.counter++
} else {
eventCounter = eventCounter + Pair(key, 1)
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is MetadataEvent -> LocalCache.consume(event)
is TextNoteEvent -> LocalCache.consume(event, relay)
is RecommendRelayEvent -> LocalCache.consume(event)
is 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 PrivateDmEvent -> LocalCache.consume(event, relay)
is DeletionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> {
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(repostEvent)
}
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
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()
}
}
}
@@ -110,33 +130,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
open fun start() {
println("DataSource: ${this.javaClass.simpleName} Start")
resetFilters()
}
open fun stop() {
println("DataSource: ${this.javaClass.simpleName} Stop")
subscriptions.values.forEach { channel ->
if (channel.typedFilters != 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)
}
Client.close(channel.id)
channel.typedFilters = null
}
}
@@ -151,17 +153,24 @@ abstract class NostrDataSource<T>(val debugName: String) {
subscriptions = subscriptions.minus(subscription.id)
}
var handlerWaiting = false
@Synchronized
fun invalidateFilters() {
if (handlerWaiting) return
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
fun invalidateFilters() {
if (handlerWaiting.getAndSet(true)) return
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
delay(200)
resetFiltersSuspend()
handlerWaiting = false
try {
delay(200)
resetFiltersSuspend()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
@@ -212,5 +221,4 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
abstract fun updateChannelFilters()
abstract fun feed(): List<T>
}
@@ -1,35 +1,23 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
override fun feed() = LocalCache.notes.values
.filter { account.isAcceptable(it) }
.filter {
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
}
.sortedBy { it.event?.createdAt }
.reversed()
override fun updateChannelFilters() {
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
@@ -1,16 +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.TextNoteEvent
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
lateinit var account: Account
override fun feed() = account.hiddenUsers()
override fun updateChannelFilters() {}
}
@@ -1,42 +1,46 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
lateinit var account: Account
object cacheListener: User.Listener() {
override fun onFollowsChange() {
resetFilters()
}
private val cacheListener: (UserState) -> Unit = {
invalidateFilters()
}
override fun start() {
if (this::account.isInitialized)
account.userProfile().subscribe(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.observeForever(cacheListener)
}
}
super.start()
}
override fun stop() {
super.stop()
if (this::account.isInitialized)
account.userProfile().unsubscribe(cacheListener)
if (this::account.isInitialized) {
GlobalScope.launch(Dispatchers.Main) {
account.userProfile().live().follows.removeObserver(cacheListener)
}
}
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val followKeys = follows.map {
it.pubkey.toHex().substring(0, 6)
it.pubkeyHex.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -44,7 +48,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
authors = followSet,
limit = 400
)
@@ -53,16 +57,6 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
val followAccountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
@@ -1,28 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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.RepostEvent
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile().taggedPosts
.filter { it.event != null }
.filter { account.isAcceptable(it) }
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
}
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {}
}
@@ -1,17 +1,40 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
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.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import nostr.postr.bechToBytes
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var hexToWatch: String? = null
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
}
@@ -21,43 +44,47 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)),
ids = listOfNotNull(hexToWatch)
)
),
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
authors = listOfNotNull(hexToWatch)
))
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 feed(): List<Note> {
return emptyList<Note>()
}
override fun updateChannelFilters() {
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(eventId: String) {
try {
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
decodePublicKey(eventId).toHex()
} else if (eventId.startsWith("note")) {
eventId.bechToBytes().toHex()
} else {
eventId
}
hexToWatch = hex
invalidateFilters()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
}
fun search(searchString: String) {
this.searchString = searchString
invalidateFilters()
}
fun clear() {
hexToWatch = null
searchString = null
}
}
@@ -1,14 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.relays.JsonFilter
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
object NostrSingleChannelDataSource: NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): TypedFilter? {
@@ -51,10 +50,6 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
val singleChannelChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList()
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
@@ -1,24 +1,88 @@
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 com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
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 createTagToAddressFilter(): List<TypedFilter>? {
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
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
)
)
}
}
}
private fun createAddressFilter(): List<TypedFilter>? {
val addressesToWatch = addressesToWatch.filter { it.event == 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(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0,8))
)
)
}
}
}
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val reactionsToWatch = eventsToWatch
if (reactionsToWatch.isEmpty()) {
return null
@@ -27,14 +91,14 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime;
val lastTime = it.lastReactionsDownloadTime
lastTime == null || lastTime < (now - 10)
}.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -45,14 +109,12 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
val threadingEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.mapNotNull { it.replyTo }
.flatten()
.filter { it.event == null }
.filter { it !is AddressableNote && it.event == null }
val interestedEvents =
(directEventsToLoad + threadingEventsToLoad)
@@ -68,8 +130,8 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
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()
)
@@ -77,37 +139,41 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
)
}
val singleEventChannel = requestNewChannel() { time ->
val singleEventChannel = requestNewChannel { time ->
eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
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 feed(): List<Note> {
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
}
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val addresses = createAddressFilter()
val addressReactions = createTagToAddressFilter()
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
singleEventChannel.typedFilters = listOfNotNull(reactions, missing, addresses, addressReactions).flatten().ifEmpty { null }
}
fun add(eventId: String) {
fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
fun remove(eventId: String) {
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,25 +1,24 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
return usersToWatch.filter { it.info?.latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
@@ -34,7 +33,8 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
tags = mapOf("p" to listOf(it.pubkeyHex)),
since = it.latestReportTime
)
)
}
@@ -46,25 +46,20 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
invalidateFilters()
}
override fun feed(): List<User> {
return synchronized(usersToWatch) {
usersToWatch.map {
LocalCache.users[it]
}.filterNotNull()
}
}
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
userChannel.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null }
userChannelOnce.typedFilters = listOfNotNull(createUserReportFilter()).flatten().ifEmpty { null }
}
fun add(userId: String) {
usersToWatch = usersToWatch.plus(userId)
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
fun remove(userId: String) {
usersToWatch = usersToWatch.minus(userId)
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
@@ -1,118 +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 com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): TypedFilter? {
if (eventsToWatch.isEmpty()) {
return null
}
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to eventsToWatch.toList())
)
)
}
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val threadToLoad = eventToWatch ?: return null
val eventsToLoad = nodes
val eventsToLoad = ThreadAssembler().findThreadFor(threadToLoad)
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
if (eventsToLoad.isEmpty()) {
return null
}
.map { it.idHex }
.toSet()
.ifEmpty { null } ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad
ids = eventsToLoad.map { it.substring(0, 8) }
)
)
}
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
return eventsToWatch.map {
LocalCache.getOrCreateNote(it)
}.sortedWith(order)
}
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)
searchRoot(it, testedNotes)
else
null
}?.filterNotNull()
if (roots != null && roots.isNotEmpty()) {
return roots[0]
}
return null
}
fun loadThread(noteId: String) {
val note = LocalCache.getOrCreateNote(noteId)
if (note.event != null) {
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
val threadRoot = searchRoot(note) ?: note
loadDown(threadRoot, thread, threadSet)
eventsToWatch = thread.map { it.idHex }.toSet()
} else {
eventsToWatch = setOf(noteId)
}
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()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
if (note !in threadSet) {
thread.add(note)
threadSet.add(note)
override fun updateChannelFilters() {
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
}
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}
fun loadThread(noteId: String?) {
eventToWatch = noteId
invalidateFilters()
}
}
@@ -1,82 +1,117 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
lateinit var account: Account
object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.getOrCreateUser(userId)
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters()
}
fun createUserInfoFilter(): TypedFilter {
return TypedFilter(
fun createUserInfoFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createUserPostsFilter(): TypedFilter {
return TypedFilter(
fun createUserPostsFilter() = user?.let {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
)
}
fun createFollowFilter(): TypedFilter {
return TypedFilter(
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(user!!.pubkeyHex),
authors = listOf(it.pubkeyHex),
limit = 1
)
)
}
fun createFollowersFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
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),
tags = mapOf("p" to 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 feed(): List<Note> {
return user?.notes
?.filter { account.isAcceptable(it) }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
override fun updateChannelFilters() {
userInfoChannel.typedFilters = listOf(
userInfoChannel.typedFilters = listOfNotNull(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter()
createFollowersFilter(),
createUserReceivedZapsFilter(),
createAcceptedAwardsFilter(),
createReceivedAwardsFilter()
).ifEmpty { null }
}
}
@@ -1,23 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
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") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
override fun feed(): List<User> {
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {}
}
@@ -1,23 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
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") {
lateinit var account: Account
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.users[userId]
resetFilters()
}
override fun feed(): List<User> {
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {}
}
@@ -0,0 +1,135 @@
package com.vitorpamplona.amethyst.service.lang
import android.util.LruCache
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import java.util.regex.Pattern
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
val dict = lnDictionary(text) + urlDictionary(text)
for (paragraph in encodeDictionary(text, dict).split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space
results.add(decodeDictionary(fixedText, dict))
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.value, pair.key, true)
}
return newText
}
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.key, pair.value, true)
}
return newText
}
private fun lnDictionary(text: String): Map<String, String> {
val matcher = lnRegex.matcher(text)
val returningList = mutableMapOf<String, String>()
val counter = 0
while (matcher.find()) {
try {
val lnInvoice = matcher.group()
val short = "Amethystlnindexer${counter}"
returningList.put(short, lnInvoice)
} catch (e: Exception) {
}
}
return returningList
}
private fun urlDictionary(text: String): Map<String, String> {
val parser = UrlDetector(text, UrlDetectorOptions.Default)
val urlsInText = parser.detect()
val counter = 0
return urlsInText.filter { !it.originalUrl.contains("") || !it.originalUrl.contains("") }.associate {
"Amethysturlindexer${counter}" to it.originalUrl
}
}
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == translateTo) {
Tasks.forCanceled()
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
translate(text, it, translateTo)
} else {
Tasks.forCanceled()
}
}
}
}
@@ -0,0 +1,163 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
if (lnaddress.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().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().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 = {
val lnurlp = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
onError("Callback URL not found in the User's lightning address server configuration")
}
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
callback?.let { callback ->
fetchLightningInvoice(callback, milliSats, message, if (allowsNostr) nostrRequest else null,
onSuccess = {
val lnInvoice = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address's invoice fetch. Check the user's lightning setup")
null
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
},
onError = onError
)
}
},
onError = onError
)
}
}
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.lnurl
package com.vitorpamplona.amethyst.service.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -129,7 +128,7 @@ object LnInvoiceUtil {
}
private fun multiplier(multiplier: String): BigDecimal {
return when (multiplier.toLowerCase()) {
return when (multiplier.lowercase()) {
"m" -> BigDecimal("0.001")
"u" -> BigDecimal("0.000001")
"n" -> BigDecimal("0.000000001")
@@ -154,4 +153,4 @@ object LnInvoiceUtil {
matcher.group()
} else null
}
}
}
@@ -0,0 +1,72 @@
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.NIP19TLVTypes
import com.vitorpamplona.amethyst.service.parseTLV
import com.vitorpamplona.amethyst.service.toInt32
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) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
fun toNAddr(): String {
val kind = kind.toByteArray()
val addr = pubKeyHex.toByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val fullArray =
byteArrayOf(NIP19TLVTypes.SPECIAL.id, dTag.size.toByte()) + dTag +
byteArrayOf(NIP19TLVTypes.AUTHOR.id, addr.size.toByte()) + addr +
byteArrayOf(NIP19TLVTypes.KIND.id, kind.size.toByte()) + kind
return Bech32.encodeBytes(hrp = "naddr", fullArray, Bech32.Encoding.Bech32)
}
companion object {
fun parse(address: String): ATag? {
return if (address.startsWith("naddr") || address.startsWith("nostr:naddr"))
parseNAddr(address)
else
parseAtag(address)
}
fun parseAtag(atag: String): ATag? {
return try {
val parts = atag.split(":")
Hex.decode(parts[1])
ATag(parts[0].toInt(), parts[1], parts[2])
} 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 = parseTLV(key.bechToBytes())
val d = tlv.get(NIP19TLVTypes.SPECIAL.id)?.get(0)?.toString(Charsets.UTF_8) ?: ""
val relay = tlv.get(NIP19TLVTypes.RELAY.id)?.get(0)?.toString(Charsets.UTF_8)
val author = tlv.get(NIP19TLVTypes.AUTHOR.id)?.get(0)?.toHexKey()
val kind = tlv.get(NIP19TLVTypes.KIND.id)?.get(0)?.let { toInt32(it) }
if (kind != null && author != null)
return ATag(kind, author, d)
}
} catch (e: Throwable) {
Log.w( "ATag", "Issue trying to Decode NIP19 ${this}: ${e.message}")
//e.printStackTrace()
}
return null
}
}
}
@@ -0,0 +1,22 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
companion object {
const val kind = 8
}
}
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
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())
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,22 @@
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag())
companion object {
const val kind = 30008
}
}
@@ -1,42 +1,45 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
class ChannelCreateEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channelInfo: ChannelData
init {
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
}
fun channelInfo() = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelData(null, null, null)
}
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
val content = if (channelInfo != null)
gson.toJson(channelInfo)
else
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 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, pubKey, createdAt, tags, content, sig)
return ChannelCreateEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
@@ -1,30 +1,26 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelHideMessageEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val eventsToHide: List<String>
init {
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
}
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {
listOf("e", it)
@@ -32,7 +28,7 @@ class ChannelHideMessageEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -1,34 +1,29 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelMessageEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val replyTos: List<String>
@Transient val mentions: List<String>
init {
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) }
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
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
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 pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("e", channel, "", "root")
)
@@ -41,7 +36,7 @@ class ChannelMessageEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -1,46 +1,43 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMetadataEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val channel: String?
@Transient val channelInfo: ChannelCreateEvent.ChannelData
init {
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
fun channelInfo() =
try {
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
} catch (e: Exception) {
throw Error("can't parse $content", e)
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
ChannelCreateEvent.ChannelData(null, null, null)
}
}
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
val content = if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val content =
if (newChannelInfo != null)
gson.toJson(newChannelInfo)
else
""
val pubKey = Utils.pubkeyCreate(privateKey)
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, pubKey, createdAt, tags, content, sig)
return ChannelMetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -1,31 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMuteUserEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val usersToMute: List<String>
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
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
usersToMute?.map {
listOf("p", it)
@@ -33,7 +29,7 @@ class ChannelMuteUserEvent (
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
return ChannelMuteUserEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,59 @@
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.toHexKey
import java.util.Date
import nostr.postr.Utils
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) {
fun follows() = try {
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
null
}
fun relayUse() = try {
if (content.isNotEmpty())
gson.fromJson(content, object: TypeToken<Map<String, ReadWrite>>() {}.type)
else
null
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", 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 java.util.Date
import nostr.postr.Utils
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,209 @@
package com.vitorpamplona.amethyst.service.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
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 java.lang.reflect.Type
import java.security.MessageDigest
import java.util.Date
import nostr.postr.Utils
import nostr.postr.toHex
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
) {
fun toJson(): String = gson.toJson(this)
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()
}
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
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!""")
}
}
fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
}
if (!Secp256k1.get().verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))) {
return false
}
return true
}
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.map { s -> s.asString }
},
content = jsonObject.get("content").asString,
sig = jsonObject.get("sig").asString
)
}
}
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)
}
}
}
class ByteArrayDeserializer : JsonDeserializer<ByteArray> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext?
): ByteArray = Hex.decode(json.asString)
}
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,40 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
class LnZapEvent (
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
fun lnInvoice() = tags.filter { it.firstOrNull() == "bolt11" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun preimage() = tags.filter { it.firstOrNull() == "preimage" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
// Keeps this as a field because it's a heavier function used everywhere.
val amount = lnInvoice()?.let { LnInvoiceUtil.getAmountInSats(it) }
fun containedPost() = try {
description()?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
null
}
companion object {
const val kind = 9735
}
}
@@ -0,0 +1,87 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.toHex
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
companion object {
const val kind = 9734
fun create(originalNote: Event, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).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,50 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
class LongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag())
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,48 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.Gson
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?)
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
}
companion object {
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), privateKey, createdAt = createdAt)
}
fun create(contactMetaData: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
val content = contactMetaData
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 MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,86 @@
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 java.util.Date
import nostr.postr.Utils
import nostr.postr.toHex
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,26 +1,23 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ReactionEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val originalPost: List<String>
@Transient val originalAuthor: List<String>
init {
originalPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
}
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
companion object {
const val kind = 7
@@ -34,11 +31,16 @@ class ReactionEvent (
}
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 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, pubKey, createdAt, tags, content, sig)
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 java.net.URI
import java.util.Date
import nostr.postr.Utils
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())
}
}
}
@@ -1,66 +1,95 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.ui.text.toUpperCase
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
// NIP 56 event.
class ReportEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val reportType: List<ReportType>
@Transient val reportedPost: List<String>
@Transient val reportedAuthor: List<String>
init {
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
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.toUpperCase()) }.firstOrNull()
if (reportType == null) {
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.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)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
)
}
fun reportedAuthor() = tags
.filter { it.firstOrNull() == "p" && it.getOrNull(1) != null }
.map {
ReportedKey(
it[1],
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) }?: defaultReportType()
)
}
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
companion object {
const val kind = 1984
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
val reportTypeTag = listOf("report", type.name.toLowerCase())
val reportPostTag = listOf("e", reportedPost.id.toHex())
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
val 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 pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
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 reportTypeTag = listOf("report", type.name.toLowerCase())
val reportAuthorTag = listOf("p", reportedUser)
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
val pubKey = Utils.pubkeyCreate(privateKey)
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
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, pubKey, createdAt, tags, content, sig)
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
enum class ReportType() {
EXPLICIT,
EXPLICIT, // Not used anymore.
ILLEGAL,
SPAM,
IMPERSONATION
IMPERSONATION,
NUDITY,
PROFANITY,
}
}
}
@@ -1,33 +1,29 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class RepostEvent (
id: ByteArray,
pubKey: ByteArray,
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: ByteArray
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient val boostedPost: List<String>
@Transient val originalAuthor: List<String>
@Transient val containedPost: Event?
init {
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
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 { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
containedPost = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
fun containedPost() = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
companion object {
@@ -36,14 +32,19 @@ class RepostEvent (
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id.toHex())
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
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 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)
return RepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import java.util.Date
import nostr.postr.Utils
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull { it.getOrNull(1) }.mapNotNull { ATag.parse(it) }
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
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()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.service.relays
import java.util.UUID
import nostr.postr.events.Event
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.model.Event
/**
* The Nostr Client manages multiple personae the user may switch between. Events are received and
@@ -23,6 +26,7 @@ object Client: RelayPool.Listener {
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()
@@ -30,6 +34,18 @@ object Client: RelayPool.Listener {
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<TypedFilter> = listOf()
@@ -50,8 +66,9 @@ object Client: RelayPool.Listener {
RelayPool.send(signedEvent)
}
fun close(subscriptionId: String){
fun close(subscriptionId: String) {
RelayPool.close(subscriptionId)
subscriptions = subscriptions.minus(subscriptionId)
}
fun disconnect() {
@@ -61,19 +78,35 @@ object Client: RelayPool.Listener {
}
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onEvent(event, subscriptionId, relay) }
}
}
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
// Releases the Web thread for the new payload.
// May need to add a processing queue if processing new events become too costly.
GlobalScope.launch(Dispatchers.Default) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
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) {
@@ -1,10 +1,11 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
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 {
@@ -14,15 +15,15 @@ object Constants {
val defaultRelays = arrayOf(
// Free relays
NewRelayListViewModel.Relay("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
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),
@@ -35,14 +36,19 @@ object Constants {
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
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,134 @@
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
)
}
}
}
@@ -3,8 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import java.util.Date
import java.util.concurrent.TimeUnit
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -12,7 +11,7 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH
}
class Relay(
@@ -22,9 +21,6 @@ class Relay(
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(100, TimeUnit.SECONDS)
.readTimeout(100, TimeUnit.SECONDS)
.callTimeout(100, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build();
@@ -34,6 +30,7 @@ class Relay(
private var isReady: Boolean = false
var eventDownloadCounter = 0
var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
@@ -91,7 +88,7 @@ class Relay(
"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"))
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
//Log.w("Relay", "Relay onOK $url, $channel")
@@ -148,6 +145,8 @@ class Relay(
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
errorCounter++
isReady = false
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
@@ -175,8 +174,8 @@ class Relay(
}
}
} else {
// waits 10 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 10) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
// sends all filters after connection is successful.
requestAndWatch()
}
@@ -186,8 +185,11 @@ class Relay(
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}
}
}
@@ -202,6 +204,13 @@ class Relay(
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,
@@ -5,7 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import nostr.postr.events.Event
import com.vitorpamplona.amethyst.service.model.Event
/**
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
@@ -94,7 +94,6 @@ object RelayPool: Relay.Listener {
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) }
}
@@ -1,10 +1,9 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import nostr.postr.JsonFilter
import com.vitorpamplona.amethyst.service.relays.JsonFilter
class TypedFilter(
val types: Set<FeedType>,
@@ -23,7 +22,7 @@ class TypedFilter(
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
return JsonArray().apply { types.forEach { add(it.name.lowercase()) } }
}
fun filterToJson(filter: JsonFilter): JsonObject {
@@ -53,4 +52,4 @@ class TypedFilter(
}
return jsonObject
}
}
}
@@ -1,37 +1,28 @@
package com.vitorpamplona.amethyst.ui
import android.net.Uri
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 coil.memory.MemoryCache
import coil.request.CachePolicy
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.bechToBytes
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -50,7 +41,7 @@ class MainActivity : ComponentActivity() {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
}
} //.logger(DebugLogger())
.respectCacheHeaders(false)
.build()
}
@@ -60,11 +51,11 @@ class MainActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext))
}
AccountScreen(accountViewModel, startingPage)
AccountScreen(accountStateViewModel, startingPage)
}
}
}
@@ -74,7 +65,8 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Only starts after login
ServiceManager.start()
}
@@ -83,4 +75,14 @@ class MainActivity : ComponentActivity() {
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,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}
@@ -1,60 +1,75 @@
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 body: RequestBody = MultipartBody.Builder()
val requestBody: RequestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"image",
"${UUID.randomUUID()}.png",
encodeImage(bitmap).toRequestBody("image/png".toMediaType())
"${UUID.randomUUID()}",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
}
}
)
.build()
val request: Request = Request.Builder()
.url("https://api.imgur.com/3/image")
.header("Authorization", "Client-ID e6aea87296f3f96")
.post(body)
.post(requestBody)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val body = response.body
if (body != null) {
try {
check(response.isSuccessful)
response.body.use { body ->
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
if (url != null)
onSuccess(url)
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)
}
})
}
@@ -17,18 +17,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.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, channel: Channel? = null) {
@@ -71,13 +70,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
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)
)
},
@@ -90,7 +89,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
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 },
@@ -105,13 +104,13 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
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)
)
},
@@ -4,12 +4,8 @@ import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
@@ -38,8 +34,6 @@ class NewChannelViewModel: ViewModel() {
channelDescription.value.text,
channelPicture.value.text
)
LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text,
@@ -1,27 +1,15 @@
package com.vitorpamplona.amethyst.ui.actions
import android.widget.Toast
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -37,11 +25,10 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
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.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -49,161 +36,172 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview
import com.vitorpamplona.amethyst.ui.components.VideoView
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 com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
val postViewModel: NewPostViewModel = viewModel()
postViewModel.load(account, baseReplyTo)
val context = LocalContext.current
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false
dismissOnClickOutside = false,
decorFitsSystemWindows = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Column(
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
postViewModel.message.text.isNotBlank()
)
}
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
postViewModel.removeFromReplyList(it)
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = "What's on your mind?",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
Surface(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
Column(modifier = Modifier.padding(10.dp).imePadding().weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
CloseButton(onCancel = {
postViewModel.cancel()
onClose()
})
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
) {
postViewModel.upload(it, context)
}
PostButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.message.text.isNotBlank()
&& !postViewModel.isUploadingImage
)
}
Row(
modifier = Modifier.fillMaxWidth().weight(1f)
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroolState)) {
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "") {
postViewModel.removeFromReplyList(it)
}
}
OutlinedTextField(
value = postViewModel.message,
onValueChange = {
postViewModel.updateMessage(it)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(8.dp)
)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.what_s_on_your_mind),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
colors = TextFieldDefaults
.outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Row(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl =
myUrlPreview.split("?")[0].lowercase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl)
.matches()
) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}
}
}
}
val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) {
if (isValidURL(myUrlPreview)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
AsyncImage(
model = myUrlPreview,
contentDescription = myUrlPreview,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.padding(top = 4.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
val userSuggestions = postViewModel.userSuggestions
if (userSuggestions.isNotEmpty()) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp
),
modifier = Modifier.heightIn(0.dp, 300.dp)
) {
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }) { index, item ->
UserLine(item, account) {
postViewModel.autocompleteWithUser(item)
}
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
}
}
}
@@ -224,7 +222,7 @@ fun CloseButton(onCancel: () -> Unit) {
backgroundColor = Color.Gray
)
) {
Text(text = "Cancel", color = Color.White)
Text(text = stringResource(R.string.cancel), color = Color.White)
}
}
@@ -243,7 +241,26 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Post", color = Color.White)
Text(text = stringResource(R.string.post), color = Color.White)
}
}
@Composable
fun SaveButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
Button(
modifier = modifier,
onClick = {
if (isActive) {
onPost()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = stringResource(R.string.save), color = Color.White)
}
}
@@ -262,7 +279,7 @@ fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Create", color = Color.White)
Text(text = stringResource(R.string.create), color = Color.White)
}
}
@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import nostr.postr.toNpub
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
class NewPostViewModel: ViewModel() {
private var account: Account? = null
@@ -30,11 +24,13 @@ class NewPostViewModel: ViewModel() {
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val imageUploadingError = MutableSharedFlow<String?>()
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?) {
fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
@@ -45,85 +41,104 @@ class NewPostViewModel: ViewModel() {
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
quote?.let {
message = TextFieldValue(message.text + "\n\n@${it.idNote()}")
}
this.account = account
}
fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int {
val replyToSize = replyTos?.size ?: 0
fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
var myMentions = mentions
if (myMentions == null) {
mentions = listOf(user)
return replyToSize + 0 // position of the user
}
fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
val index = myMentions.indexOf(user)
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
if (index >= 0) return replyToSize + index
myMentions = myMentions.plus(user)
mentions = myMentions
return replyToSize + myMentions.indexOf(user)
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// Moves @npub to mentions
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.type == "npub") {
addUserToMentions(LocalCache.getOrCreateUser(results.keyHex))
} else if (results?.type == "note") {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.keyHex))
}
}
}
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val results = parseDirtyWordForKey(word)
if (results?.type == "npub") {
val user = LocalCache.getOrCreateUser(results.keyHex)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.type == "note") {
val note = LocalCache.getOrCreateNote(results.keyHex)
val index = addUserToMentionsIfNotInAndReturnIndex(user)
val newWord = "#[${index}]"
newWord + restOfWord
} else {
word
}
} catch (e: Exception) {
// if it can't parse the key, don't try to change.
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
}.joinToString(" ")
}.joinToString("\n")
if (originalNote?.channel != null) {
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
if (originalNote?.channel() != null) {
account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions)
} else {
account?.sendPost(newMessage, replyTos, mentions)
}
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun upload(it: Uri, context: Context) {
val img = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(context.contentResolver, it)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, it))
}
isUploadingImage = true
img?.let {
ImageUploader.uploadImage(img) {
message = TextFieldValue(message.text + "\n\n" + it)
ImageUploader.uploadImage(
uri = it,
contentResolver = context.contentResolver,
onSuccess = { imageUrl ->
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
urlPreview = findUrlInMessage()
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image")
}
}
}
)
}
fun cancel() {
message = TextFieldValue("")
urlPreview = null
isUploadingImage = false
mentions = null
}
fun findUrlInMessage(): String? {
@@ -157,7 +172,7 @@ class NewPostViewModel: ViewModel() {
userSuggestionAnchor?.let {
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
val lastWordStart = it.end - lastWord.length
val wordToInsert = "@${item.pubkey.toNpub()} "
val wordToInsert = "@${item.pubkeyNpub()} "
message = TextFieldValue(
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -28,18 +22,14 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -48,12 +38,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -62,6 +51,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@@ -128,6 +118,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onToggleSearch = { postViewModel.toggleSearch(it) },
onDelete = { postViewModel.deleteRelay(it) }
)
@@ -153,7 +144,7 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Relay Address",
text = stringResource(R.string.relay_address),
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -161,12 +152,12 @@ fun ServerConfigHeader() {
}
}
Column(Modifier.weight(1f)) {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
Text(
text = "Posts",
text = stringResource(R.string.posts),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -176,7 +167,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Posts",
text = stringResource(id = R.string.posts),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -186,7 +177,7 @@ fun ServerConfigHeader() {
Spacer(modifier = Modifier.size(10.dp))
Text(
text = "Errors",
text = stringResource(R.string.errors),
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
@@ -194,6 +185,16 @@ fun ServerConfigHeader() {
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = "Spam",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(2.dp))
}
}
}
@@ -206,16 +207,17 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onToggleSearch: (RelaySetupInfo) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -229,7 +231,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(15.dp),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
tint = Color.Red
)
}
@@ -254,8 +258,10 @@ fun ServerConfig(
) {
Icon(
painterResource(R.drawable.ic_home),
"Home Feed",
modifier = Modifier.padding(end = 5.dp).size(15.dp),
stringResource(R.string.home_feed),
modifier = Modifier
.padding(end = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -267,8 +273,10 @@ fun ServerConfig(
) {
Icon(
painterResource(R.drawable.ic_dm),
"Private Message Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.private_message_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -280,8 +288,10 @@ fun ServerConfig(
) {
Icon(
imageVector = Icons.Default.Groups,
"Public Chat Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.public_chat_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -293,17 +303,35 @@ fun ServerConfig(
) {
Icon(
imageVector = Icons.Default.Public,
"Global Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
stringResource(R.string.global_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleSearch(item) }
) {
Icon(
imageVector = Icons.Default.Search,
stringResource(R.string.search_feed),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.feedTypes.contains(FeedType.SEARCH)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
}
}
Column(Modifier.weight(1f)) {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
@@ -312,7 +340,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -334,7 +364,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
@@ -352,7 +384,9 @@ fun ServerConfig(
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
modifier = Modifier
.padding(horizontal = 5.dp)
.size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
@@ -363,6 +397,21 @@ fun ServerConfig(
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.DeleteSweep,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
@@ -376,14 +425,14 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Add a Relay") },
label = { Text(text = stringResource(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
@@ -422,9 +471,9 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true
@@ -436,7 +485,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Text(text = "Add", color = Color.White)
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}
@@ -1,32 +1,22 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.model.ContactListEvent
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) {
@@ -37,7 +27,6 @@ class NewRelayListViewModel: ViewModel() {
fun create(ctx: Context) {
relays.let {
account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
clear(ctx)
@@ -45,7 +34,15 @@ class NewRelayListViewModel: ViewModel() {
fun clear(ctx: Context) {
_relays.update {
val relayFile = account.userProfile().relays
var relayFile = account.userProfile().relays
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
if (relayFile?.none { it.key == Constants.forcedRelayForSearch.url } == true) {
relayFile = relayFile + Pair(
Constants.forcedRelayForSearch.url, ContactListEvent.ReadWrite(Constants.forcedRelayForSearch.read, Constants.forcedRelayForSearch.write)
)
}
if (relayFile != null)
relayFile.map {
@@ -55,8 +52,9 @@ class NewRelayListViewModel: ViewModel() {
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
@@ -65,13 +63,14 @@ class NewRelayListViewModel: ViewModel() {
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
@@ -79,51 +78,58 @@ class NewRelayListViewModel: ViewModel() {
}
}
fun deleteRelay(relay: Relay) {
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: Relay) {
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: Relay) {
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: Relay) {
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: Relay) {
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))
}
}
fun toggleSearch(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.SEARCH)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
@@ -17,15 +17,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.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
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -64,7 +63,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
postViewModel.create()
onClose()
},
postViewModel.userName.value.isNotBlank()
true
)
}
@@ -72,13 +71,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Display Name") },
label = { Text(text = stringResource(R.string.display_name)) },
modifier = Modifier.weight(1f),
value = postViewModel.displayName.value,
onValueChange = { postViewModel.displayName.value = it },
placeholder = {
Text(
text = "My display name",
text = stringResource(R.string.my_display_name),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -91,13 +90,13 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Text("@", Modifier.padding(5.dp))
OutlinedTextField(
label = { Text(text = "Username") },
label = { Text(text = stringResource(R.string.username)) },
modifier = Modifier.weight(1f),
value = postViewModel.userName.value,
onValueChange = { postViewModel.userName.value = it },
placeholder = {
Text(
text = "My username",
text = stringResource(R.string.my_username),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -108,7 +107,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "About me") },
label = { Text(text = stringResource(R.string.about_me)) },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
@@ -116,7 +115,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
onValueChange = { postViewModel.about.value = it },
placeholder = {
Text(
text = "About me",
text = stringResource(id = R.string.about_me),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
@@ -129,7 +128,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Avatar URL") },
label = { Text(text = stringResource(R.string.avatar_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.picture.value,
onValueChange = { postViewModel.picture.value = it },
@@ -145,7 +144,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Banner URL") },
label = { Text(text = stringResource(R.string.banner_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.banner.value,
onValueChange = { postViewModel.banner.value = it },
@@ -161,7 +160,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "Website URL") },
label = { Text(text = stringResource(R.string.website_url)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.website.value,
onValueChange = { postViewModel.website.value = it },
@@ -177,7 +176,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "NIP-05") },
label = { Text(text = stringResource(R.string.nip_05)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.nip05.value,
onValueChange = { postViewModel.nip05.value = it },
@@ -192,7 +191,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN Address") },
label = { Text(text = stringResource(R.string.ln_address)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnAddress.value,
onValueChange = { postViewModel.lnAddress.value = it },
@@ -205,6 +204,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringResource(R.string.ln_url_outdated)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = stringResource(R.string.lnurl),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}
@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -28,18 +29,19 @@ class NewUserMetadataViewModel: ViewModel() {
account.userProfile().let {
userName.value = it.bestUsername() ?: ""
displayName.value = it.bestDisplayName() ?: ""
about.value = it.info.about ?: ""
picture.value = it.info.picture ?: ""
banner.value = it.info.banner ?: ""
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
website.value = it.info?.website ?: ""
nip05.value = it.info?.nip05 ?: ""
lnAddress.value = it.info?.lud16 ?: ""
lnURL.value = it.info?.lud06 ?: ""
}
}
fun create() {
// Tries to not delete any existing attribute that we do not work with.
val latest = account.userProfile().latestMetadata
val latest = account.userProfile().info?.latestMetadata
val currentJson = if (latest != null) {
ObjectMapper().readTree(
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
@@ -47,16 +49,17 @@ class NewUserMetadataViewModel: ViewModel() {
} else {
ObjectMapper().createObjectNode()
}
currentJson.put("name", userName.value)
currentJson.put("username", userName.value)
currentJson.put("display_name", displayName.value)
currentJson.put("displayName", displayName.value)
currentJson.put("picture", picture.value)
currentJson.put("banner", banner.value)
currentJson.put("website", website.value)
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
currentJson.put("name", userName.value.trim())
currentJson.put("username", userName.value.trim())
currentJson.put("display_name", displayName.value.trim())
currentJson.put("displayName", displayName.value.trim())
currentJson.put("picture", picture.value.trim())
currentJson.put("banner", banner.value.trim())
currentJson.put("website", website.value.trim())
currentJson.put("about", about.value.trim())
currentJson.put("nip05", nip05.value.trim())
currentJson.put("lud16", lnAddress.value.trim())
currentJson.put("lud06", lnURL.value.trim())
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}
@@ -0,0 +1,86 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.image_saved_to_the_gallery),
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
localContext.getString(R.string.failed_to_save_the_image),
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = stringResource(id = R.string.save), color = Color.White)
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -8,25 +9,30 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
fun UploadFromGallery(
isUploading: Boolean,
onImageChosen: (Uri) -> Unit,
) {
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }
@@ -44,18 +50,30 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
modifier = Modifier
.align(Alignment.TopCenter)
.padding(4.dp),
enabled = !isUploading,
onClick = {
showGallerySelect = true
}
) {
Text("Upload Image")
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
}
}
}
} else {
Column {
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
Text("Upload Image")
Button(
onClick = { cameraPermissionState.launchPermissionRequest() },
enabled = !isUploading,
) {
if (!isUploading) {
Text(stringResource(R.string.upload_image))
} else {
Text(stringResource(R.string.uploading))
}
}
}
}
@@ -9,8 +9,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,14 +16,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account) {
@@ -45,7 +40,7 @@ fun NewChannelButton(account: Account) {
) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = "New Channel",
contentDescription = stringResource(R.string.new_channel),
modifier = Modifier.size(26.dp),
tint = Color.White
)
@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.DefaultAlpha
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.LocalImageLoader
import java.util.Base64
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null
// Fixes Image size to reduce pings to servers for each size used in the app
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
}
}
@Composable
fun AsyncImageProxy(
model: ResizeImage,
contentDescription: String?,
modifier: Modifier = Modifier,
placeholder: Painter? = null,
error: Painter? = null,
fallback: Painter? = error,
onLoading: ((AsyncImagePainter.State.Loading) -> Unit)? = null,
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null,
onError: ((AsyncImagePainter.State.Error) -> Unit)? = null,
alignment: Alignment = Alignment.Center,
contentScale: ContentScale = ContentScale.Fit,
alpha: Float = DefaultAlpha,
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality,
) {
if (model.url == null) {
AsyncImage(
model = model.url,
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
} else {
AsyncImage(
model = model.proxyUrl(),
contentDescription = contentDescription,
modifier = modifier,
placeholder = placeholder,
error = error,
fallback = fallback,
onLoading = onLoading,
onSuccess = onSuccess,
onError = onError,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality
)
}
}
@@ -8,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
@@ -4,12 +4,9 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
@@ -18,7 +15,7 @@ fun ClickableNoteTag(
navController: NavController
) {
ClickableText(
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
onClick = { navController.navigate("Note/${baesNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -9,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable
@@ -9,13 +9,8 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import nostr.postr.toNpub
@Composable
fun ClickableRoute(
@@ -25,7 +20,7 @@ fun ClickableRoute(
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.liveMetadata.observeAsState()
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
@@ -38,24 +33,24 @@ fun ClickableRoute(
)
} else {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live.observeAsState()
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel != null) {
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote} "),
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
@@ -15,7 +15,7 @@ fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.liveMetadata.observeAsState()
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
@@ -0,0 +1,90 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
//CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
RichTextViewer(
text,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
//}
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}
}
}
@@ -22,12 +22,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
@@ -67,7 +69,7 @@ fun InvoicePreview(lnInvoice: String) {
)
Text(
text = "Lightning Invoice",
text = stringResource(R.string.lightning_invoice),
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
@@ -78,20 +80,25 @@ fun InvoicePreview(lnInvoice: String) {
amount?.let {
Text(
text = "${amount.toInt()} sats",
text = "${
NumberFormat.getInstance().format(amount)
} ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
startActivity(context, intent, null)
}
},
@@ -100,7 +107,7 @@ fun InvoicePreview(lnInvoice: String) {
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Pay", color = Color.White, fontSize = 20.sp)
Text(text = stringResource(R.string.pay), color = Color.White, fontSize = 20.sp)
}
}
}

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