Compare commits

...
582 Commits
Author SHA1 Message Date
Vitor Pamplona 2e7a955ada v0.68.2 2023-07-13 19:30:52 -04:00
Vitor Pamplona 4aa961437b formatting 2023-07-13 19:23:37 -04:00
Vitor Pamplona 0d9399a1a4 Fixing Right To Left Text 2023-07-13 19:23:26 -04:00
Vitor PamplonaandGitHub 9b62cf563a Merge pull request #502 from greenart7c3/main
Aways show images/videos in the dialog screen
2023-07-13 18:04:20 -04:00
greenart7c3 7c525e2f2f show videos as links 2023-07-13 18:07:57 -03:00
greenart7c3 63888d55fd fix image loading in ZoomableImageDialog 2023-07-13 17:57:54 -03:00
Vitor Pamplona 7287ead851 v0.68.1 2023-07-13 13:17:20 -04:00
Vitor PamplonaandGitHub 6fc829f4f1 Merge pull request #486 from greenart7c3/load_media
Optional media pre-loading
2023-07-13 13:14:18 -04:00
Vitor PamplonaandGitHub d12b0ce9be Merge pull request #500 from akiomik/fix-string-resource-mapping
Fix string resource mapping when like clicked
2023-07-13 12:56:00 -04:00
Vitor PamplonaandGitHub 393714eb42 Merge pull request #499 from akiomik/extract-block-report-resource
Extract string resource for Block / Report
2023-07-13 12:55:33 -04:00
Vitor Pamplona fe363b8a2d Updates readme 2023-07-13 11:27:52 -04:00
Vitor Pamplona 064ce7e6e6 v0.68.0 2023-07-13 11:26:35 -04:00
Vitor Pamplona af72846998 Support for Classifieds 2023-07-13 11:20:03 -04:00
Vitor Pamplona 899b4f7c90 Quick fix for multiple emoji's in one line 2023-07-13 11:19:07 -04:00
Akiomi Kamakura 2e5b41b711 Add ja to locales_config.xml 2023-07-13 22:57:59 +09:00
Vitor Pamplona 809f048b44 Merge remote-tracking branch 'origin/HEAD' 2023-07-13 08:52:58 -04:00
Vitor Pamplona 51c0c44a2e Formatting 2023-07-13 08:52:47 -04:00
Vitor Pamplona 811d6d8c67 Removes emoji package list selection from Profile threads. 2023-07-13 08:51:54 -04:00
Akiomi Kamakura c1b9bf42ad Fix string resource mapping when like clicked 2023-07-13 11:05:17 +09:00
Akiomi Kamakura cafe742cd8 Extract string resource for Block / Report 2023-07-13 11:00:15 +09:00
Vitor PamplonaandGitHub ba50ec052b Merge pull request #498 from akiomik/i18n-ja
Add i18n support for ja
2023-07-12 19:59:31 -04:00
Vitor Pamplona 386f97abf8 Updates readme 2023-07-12 19:58:57 -04:00
7678b1b100 Add i18n support for ja
Co-authored-by: h3y6e <h3y6e@pm.me>
Co-authored-by: ys3669 <ys3669jp@gmail.com>
2023-07-13 07:51:49 +09:00
Vitor Pamplona 9df82a78e7 v0.67.1 2023-07-12 18:19:47 -04:00
Vitor Pamplona c87466ada4 when clicking in a repost of a note, redirects the user to the note directly. 2023-07-12 18:19:40 -04:00
Vitor Pamplona 135eb6763a Adjustments to allow removing of the selected emoji packs from the reaction setup 2023-07-12 18:19:20 -04:00
Vitor Pamplona 24a0be9c3a v0.66.7 2023-07-12 17:21:49 -04:00
Vitor Pamplona f090bc82ae - Support for Emoji Packs
- Support for Personal Emoji Lists
- Support for Custom emoji Reactions
2023-07-12 17:20:06 -04:00
greenart7c3 3254479ee5 add settings icon 2023-07-12 11:41:37 -03:00
greenart7c3 6cc4266435 appcompat needed for language picker and theme 2023-07-12 11:31:13 -03:00
greenart7c3 3e48b293b0 create ThemeViewModel using viewModel contructor 2023-07-12 11:31:13 -03:00
greenart7c3 492b9494ec fix theme not changing after changing language 2023-07-12 11:31:12 -03:00
greenart7c3 b31d2d49d6 do not load preview of long form text if automaticallyShowUrlPreview is false 2023-07-12 11:31:12 -03:00
greenart7c3 245bd4a44d fix lint 2023-07-12 11:31:12 -03:00
greenart7c3 6d4a887d00 use accountviewmodel as parameter instead of MutableState 2023-07-12 11:31:12 -03:00
greenart7c3 f95023f59d replace imageBox with url link with download button 2023-07-12 11:31:12 -03:00
greenart7c3 e69c1cf3a9 remove unused showImage parameters 2023-07-12 11:31:12 -03:00
greenart7c3 b5229f5fe1 only load one image when theres multiple images in the note 2023-07-12 11:31:12 -03:00
greenart7c3 e2ef7cfd70 add string resources 2023-07-12 11:31:12 -03:00
greenart7c3 6e89e69fb6 refactor settings to use TextSpinner and save settings when selecting an option 2023-07-12 11:31:11 -03:00
greenart7c3 1e6ee67932 move back settings to drawer 2023-07-12 11:31:11 -03:00
greenart7c3 0fead83279 fix load image box size 2023-07-12 11:31:11 -03:00
greenart7c3 66952c9212 add url preview settings 2023-07-12 11:31:11 -03:00
greenart7c3 38bb9f457d fix statusbar color when changing theme 2023-07-12 11:31:11 -03:00
greenart7c3 3e615bb152 check for automaticallyStartPlayback setting 2023-07-12 11:31:11 -03:00
greenart7c3 f83a19260f fix build after rebase 2023-07-12 11:31:11 -03:00
greenart7c3 69c3c41196 move drawer options to settings screen 2023-07-12 11:31:11 -03:00
greenart7c3 6c1a4f3971 replace load image button with a rectangle box 2023-07-12 11:31:10 -03:00
greenart7c3 035ba899ee add language picker 2023-07-12 11:31:10 -03:00
greenart7c3 33cc261b62 add settings to change theme 2023-07-12 11:31:10 -03:00
greenart7c3 fee6f19d5e check for load images option 2023-07-12 11:31:10 -03:00
greenart7c3 68e8a13f73 add options to disable auto playback e show images in the settings screen 2023-07-12 11:31:10 -03:00
greenart7c3 a639c3f36d add media settings 2023-07-12 11:31:10 -03:00
greenart7c3 a5f10fef60 created settings screen 2023-07-12 11:31:10 -03:00
greenart7c3 d334c04c47 do not autoplay videos when on mobile data 2023-07-12 11:31:09 -03:00
greenart7c3 7c3d9aad1d add ConnectivityStatus singleton 2023-07-12 11:31:09 -03:00
greenart7c3 ceb81aa2bf created button to load images] 2023-07-12 11:31:09 -03:00
Vitor Pamplona 3ee743ce1a v0.66.7 2023-07-12 10:21:02 -04:00
Vitor Pamplona d1ffea5432 BugFix for a race condition when deleting events. 2023-07-12 10:20:24 -04:00
Vitor Pamplona 628e73ef69 BugFix for app crashing when the ln invoice is blank 2023-07-12 10:06:27 -04:00
Vitor Pamplona 764928a492 Displays custom emoji reactions 2023-07-12 08:21:10 -04:00
Vitor Pamplona 88638b0cae Fixes the markdown issue with images overlapping the text 2023-07-12 08:16:05 -04:00
Vitor Pamplona 51384e654b Avoiding too much detail in the Log 2023-07-11 17:45:38 -04:00
Vitor Pamplona aecfaa5fd8 Adds more checks for work in the main thread 2023-07-10 17:22:04 -04:00
Vitor Pamplona fc5089f7f1 Updates exoplayer version 2023-07-10 14:28:42 -04:00
Vitor Pamplona 25520e7d2a Making nav(route) threaded operation 2023-07-10 14:13:27 -04:00
Vitor Pamplona 7ea5be0152 Refactoring of Badge Box codes and Time classes 2023-07-10 13:50:49 -04:00
Vitor PamplonaandGitHub 9a7e678bbe Merge pull request #496 from AutumnSunshine/main
Update strings.xml for Tamil
2023-07-09 19:00:17 -04:00
Vitor Pamplona 1f0ca70c47 Adds rendering of approval posts to the NoteMaster of a thread 2023-07-09 18:58:30 -04:00
Vitor Pamplona 93705e5baf v0.66.6 2023-07-09 12:46:56 -04:00
Vitor Pamplona 72e8fa3679 Moves the toggle to only be active in NIP94 and NIP95 images 2023-07-09 12:46:02 -04:00
Vitor Pamplona c08ce91197 v0.66.5 2023-07-09 12:11:18 -04:00
Vitor Pamplona b520a0faed Adds sensitive-content tags to NIP94 and NIP95 media 2023-07-09 12:09:21 -04:00
AutumnSunshineandGitHub 80837abab5 Update strings.xml for Tamil
Added the communities & group translations for Tamil
2023-07-09 11:44:48 +05:30
Vitor Pamplona 5c78135f1a v0.66.4 2023-07-08 17:05:24 -04:00
Vitor Pamplona 386658ed37 Updates vico version 2023-07-08 17:04:59 -04:00
Vitor Pamplona ab8b0beb02 Making sure multi-threading doesn't duplicate notes in the screen. 2023-07-08 16:35:07 -04:00
Vitor Pamplona bc89062681 Prints Debug information when the app pauses. 2023-07-08 15:46:46 -04:00
Vitor Pamplona 14685be92d Adds limits to how many messages referencing an event can be downloaded. 2023-07-08 15:46:31 -04:00
Vitor Pamplona 6d565a2960 Simplifies the work of finding an event by relays. 2023-07-08 15:46:02 -04:00
Vitor Pamplona 3fc247d85c Caches participant counting process to speed sorting up 2023-07-08 15:37:05 -04:00
Vitor Pamplona cfbc4536c6 v0.66.3 2023-07-07 17:15:02 -04:00
Vitor Pamplona dcf825c43e Slightly faster reactions and zap icons 2023-07-07 17:13:20 -04:00
Vitor Pamplona 8ae05d9964 Formatting 2023-07-07 17:11:47 -04:00
Vitor Pamplona f11faa87b1 Fix from full height to full screen 2023-07-07 17:09:42 -04:00
Vitor Pamplona bfbaabd7e8 Fixes translations from Japanese with urls breaking the url 2023-07-07 15:30:51 -04:00
Vitor Pamplona d35d6a44fe Adding content-sensitivity warnings for LiveStreams 2023-07-07 15:17:20 -04:00
Vitor Pamplona 9c7265565e Avoids triggering the Notification bubble when the live event is simply updated. 2023-07-07 14:40:07 -04:00
Vitor Pamplona 293b023add - Adds hashtags to the description of communities and channels
- Displays descriptions with Translations.
2023-07-07 14:31:04 -04:00
Vitor Pamplona bee5c1bfa6 v0.66.2 2023-07-07 12:29:22 -04:00
Vitor Pamplona c93a6dffc4 Creates a fallback for Android9 on OPPO, which doesn't seem able to parse the Japanese regex. 2023-07-07 12:27:57 -04:00
Vitor Pamplona 60dadc0088 BugFix for invalid URLs crashing the app. 2023-07-07 12:15:28 -04:00
Vitor Pamplona a26c7e3d97 Fixes caching of time display. 2023-07-07 09:45:21 -04:00
Vitor Pamplona 9e2b2cedd6 v0.66.1 2023-07-06 21:10:44 -04:00
Vitor Pamplona 636d59659d Activates reactions and zaps to the channel and community headers. 2023-07-06 21:04:07 -04:00
Vitor Pamplona 63e8391893 Allows notification to the channel headers 2023-07-06 21:03:51 -04:00
Vitor Pamplona 2f354e4b81 Adds EOSE treatment for the Discovery datasource 2023-07-06 21:03:36 -04:00
Vitor Pamplona b3a369d91b Removes top bar for Community and Hashtag posts 2023-07-06 21:03:05 -04:00
Vitor Pamplona 4406532994 Loads the last 50 messages per following chat. 2023-07-06 21:02:47 -04:00
Vitor Pamplona 8fd5176e22 Makes sure all services stop when the app pauses. 2023-07-06 21:02:18 -04:00
Vitor Pamplona e3396065c6 Reducing size of the discovery tab strings. 2023-07-06 17:38:55 -04:00
Vitor Pamplona 84600c016e Fixing for emoji families 2023-07-06 17:36:11 -04:00
Vitor Pamplona 64d96ab1d7 Fixes Emoji Joining issues. 2023-07-06 17:24:42 -04:00
Vitor Pamplona 6d6720a511 Adds reactions to Chats and Communities 2023-07-06 15:51:46 -04:00
Vitor Pamplona 17232a1909 Moves communities from /c/name to /n/name 2023-07-06 15:51:28 -04:00
Vitor Pamplona eb02049507 Turns parsing failures into warnings. 2023-07-06 15:51:14 -04:00
Vitor Pamplona ed7ad2f51b Adds communities to the readme 2023-07-06 15:50:52 -04:00
Vitor Pamplona 3e0202e6cb v0.66.0 2023-07-06 11:44:33 -04:00
Vitor Pamplona d59a68ec2e Formatting 2023-07-06 11:41:11 -04:00
Vitor Pamplona f249884907 Fixes the participants on a community index 2023-07-06 11:41:03 -04:00
Vitor Pamplona c823d1689f Displays Approval Notes in the notifications 2023-07-06 11:21:35 -04:00
Vitor Pamplona b10f268e69 - Adds a button to create community posts from the Community screen
- Adds a reference to the reply/community in the new post screen.
2023-07-06 10:57:41 -04:00
Vitor Pamplona eff21c4f8f Minimizes the height of the tabs 2023-07-06 10:22:44 -04:00
Vitor Pamplona 5537208abb - Adds Basic Support for Communities (172)
- Adds Community discovery screens
- Adds Public Chat discovery
- Adds Community Follow/Unfollow
- Renders Community Posts in feed
- Creates a summary of verified participants in Communities/Chats/Streams
- Restructures Hashtag Screen to the new Screen Building structure
- Remembers scroll position in Live, Community and Chat discovery
2023-07-06 10:11:25 -04:00
Vitor PamplonaandGitHub 53a5d3e88e Merge pull request #491 from ShinoharaTa/fix_broken_japanese_view
Fixed a bug in Japanese string display
2023-07-05 15:04:28 -04:00
Vitor PamplonaandGitHub 20f840fd99 Merge pull request #489 from davotoula/translations_update
Updated se/cs/ge translations
2023-07-05 15:00:47 -04:00
tshinohara dc940c0608 fix lint error. 2023-07-06 03:05:17 +09:00
tshinohara f5c77d41f1 Bugfix: Url parse disabled in other texts. 2023-07-06 02:47:29 +09:00
tshinohara 014c613577 Bugfix: Change matching pattern. 2023-07-06 01:26:15 +09:00
tshinohara d369c2c658 Bugfix: Japanese url export disable 2. 2023-07-06 01:23:31 +09:00
tshinohara 536868d3e5 Bugfix: Japanese url export disable. 2023-07-06 01:20:54 +09:00
Vitor Pamplona 5706738898 Initial Support for Communities 2023-07-05 11:22:03 -04:00
Vitor Pamplona 2947c2c7d0 Replaces the Live bar in the top of the feed for Notification dots in the LiveStream bottom button. 2023-07-05 10:28:21 -04:00
Vitor Pamplona 18ac527629 BugFix for invalid hexes in the hex index 2023-07-05 09:09:17 -04:00
Vitor Pamplona 6d80d14421 Updates dependencies 2023-07-05 09:04:42 -04:00
David Kaspar e60d8c5c20 Updated se/cs/ge translations 2023-07-05 13:03:27 +01:00
Vitor Pamplona 51b4cf444a v0.65.1 2023-07-04 21:02:22 -04:00
Vitor Pamplona f78ec5cc90 Performance updates 2023-07-04 20:55:01 -04:00
Vitor Pamplona fd6a63a76c Fixes https://github.com/vitorpamplona/amethyst/issues/487 2023-07-04 19:25:21 -04:00
Vitor Pamplona e0f186bb94 BugFix for not rendering images on markdown when there is no NIP19 reference in the text. 2023-07-04 12:19:56 -04:00
Vitor Pamplona 9fadab5bb9 v0.65.0 2023-07-04 11:45:35 -04:00
Vitor Pamplona ea768fb560 Base Streaming view 2023-07-04 11:44:28 -04:00
Vitor Pamplona 71cb18f360 v0.64.4 2023-07-03 08:53:30 -04:00
Vitor Pamplona 3a1694ac9a Renames Hex Check method for better understanding. 2023-07-03 08:36:33 -04:00
Vitor Pamplona 91c47b6e87 BugFix for delayed update of the last messages in the Messages screen 2023-07-03 08:36:12 -04:00
Vitor Pamplona fe4e03df39 v0.64.3 2023-07-02 16:38:28 -04:00
Vitor Pamplona d110b9fbbf BugFix for IndexOutOfBoundsException 2023-07-02 15:50:07 -04:00
Vitor Pamplona dc8c177f20 New Icon for verified follows 2023-07-02 15:47:07 -04:00
Vitor Pamplona 1beacd240e Fixing the lack of the checkmark in the verification icons 2023-07-02 15:46:41 -04:00
Vitor PamplonaandGitHub 083394b928 Merge pull request #485 from believethehype/main
Added #Cashu
2023-07-02 14:31:54 -04:00
Believethehype 6de8d9d7e0 Update HashtagIcon.kt 2023-07-02 17:02:58 +02:00
Vitor Pamplona fb4457e767 Fix for last PR 2023-07-01 18:58:01 -04:00
Vitor PamplonaandGitHub d4c1907349 Merge pull request #484 from AutumnSunshine/AutumnSunshine-updated-Tamil-translation
Update strings.xml for Tamil translation (values-ta)
2023-07-01 18:42:29 -04:00
AutumnSunshineandGitHub c92926f3ab Update strings.xml
Updated Tamil translations for newer additions, building on Vivek's earlier translation.
2023-07-02 03:22:15 +05:30
Vitor Pamplona 8f4f8f7953 v0.64.2 2023-07-01 17:27:00 -04:00
Vitor Pamplona 5a3ea1c258 Shows channel picture on the public chat channel's header instead of the creator's picture. 2023-07-01 17:24:54 -04:00
Vitor Pamplona a567ec70ad v0.64.1 2023-07-01 17:19:42 -04:00
Vitor Pamplona e402081777 Adds a Toast for not having a private key when following/unfollowing people 2023-07-01 17:16:56 -04:00
Vitor Pamplona ed9c27341f BugFix for not changing background color on Notes 2023-07-01 17:16:32 -04:00
Vitor Pamplona 56d9c9a50f Moves away from drawBehind and uses background color instead. It feels faster at first glance. 2023-07-01 16:57:45 -04:00
Vitor Pamplona d2a22f4ca0 Moves from drawBehind to .background because it's faster. 2023-07-01 15:52:51 -04:00
Vitor Pamplona 09582fd0b1 Removes the need to draw a background with the theme's background color on the verification symbol 2023-07-01 15:33:13 -04:00
Vitor Pamplona 1d5dfbfd29 Reduces the max grouped reactions on Notifications to 30 to increase speed of scroll. 2023-07-01 11:58:40 -04:00
Vitor Pamplona 044d47adad Merge remote-tracking branch 'origin/HEAD' 2023-07-01 11:52:51 -04:00
Vitor Pamplona cd9465c0e7 Moving screen updates from 300ms to 500ms to avoid triggering updates while animations are running. 2023-07-01 11:52:40 -04:00
Vitor Pamplona d179c93c0b New RichText engine to help with testing classes. 2023-07-01 11:51:46 -04:00
Vitor PamplonaandGitHub 3c84eae647 Merge pull request #481 from greenart7c3/main
fix relay tab not working in profile page
2023-06-30 20:45:58 -04:00
greenart7c3 53a146d0e5 fix relay tab not working in profile page 2023-06-30 20:28:40 -03:00
Vitor Pamplona 57db7f0272 Moves some of the modifiers to singleton variables 2023-06-30 08:58:28 -04:00
Vitor Pamplona b820b6564f BugFix Crashing relay screen on invalid URLs 2023-06-30 08:58:14 -04:00
Vitor Pamplona 3908c42c7f Reduces the use of hex 2023-06-30 08:57:57 -04:00
Vitor Pamplona 1793fe40bf v0.64.0 2023-06-29 18:13:48 -04:00
Vitor Pamplona 0857695332 Moves global to home as a list and search to the top bar. 2023-06-29 18:13:09 -04:00
Vitor Pamplona a7b3bac8f8 Slims down the live headers 2023-06-29 18:12:22 -04:00
Vitor Pamplona e6f122d8b7 BugFix for trimmed paragraphs losing indentation 2023-06-29 16:46:44 -04:00
Vitor Pamplona b2cdc2ddef BugFix: Not showing display name on [n] tags 2023-06-29 16:28:47 -04:00
Vitor Pamplona 94057059bb Logging out the time to display reactions 2023-06-29 16:17:29 -04:00
Vitor Pamplona 927ef17896 Improved AsyncImage loading states. 2023-06-29 15:09:08 -04:00
Vitor Pamplona 33f915879b Makes NIP05 work in domain names only (No need for the _@ in the "_@domain" address) 2023-06-29 15:08:46 -04:00
Vitor Pamplona 98bca48359 Changes to not preview images when CanPreview is false. 2023-06-29 14:19:35 -04:00
Vitor Pamplona 171f763908 Additional checks for main thread in Notifications 2023-06-29 12:42:11 -04:00
Vitor Pamplona 605d83e530 v0.63.0 2023-06-29 12:32:45 -04:00
Vitor Pamplona 5f710b7304 Adds #tunestr icon 2023-06-29 12:29:08 -04:00
Vitor Pamplona 1c6593fb17 Adds alt-text to screenshots 2023-06-29 12:15:03 -04:00
Vitor Pamplona 33fe34d3b3 updates screenshot titles 2023-06-29 12:13:56 -04:00
Vitor Pamplona e257fec92c Breaking screenshots in two lines 2023-06-29 12:10:26 -04:00
Vitor Pamplona 6a70a6c72f Showing screenshots in the homepage 2023-06-29 12:09:05 -04:00
Vitor Pamplona 270d6b411a Updating Amethyst Logo to Zitron's 2023-06-29 12:00:20 -04:00
Vitor PamplonaandGitHub 803b1e861e Merge pull request #480 from greenart7c3/main
added followed tags in profile screen
2023-06-29 10:03:33 -04:00
Vitor Pamplona 7300dc3d78 No need to point to the release section after the GitHub badge has been added. 2023-06-29 09:52:24 -04:00
Vitor Pamplona 405d64402b decreases height 2023-06-29 09:42:55 -04:00
Vitor Pamplona fe1bff1074 removes space between badges 2023-06-29 09:41:44 -04:00
Vitor Pamplona 2952ca1978 Reverting change 2023-06-29 09:39:14 -04:00
Vitor Pamplona 32dcd7747b slight change in borders 2023-06-29 09:38:20 -04:00
Vitor Pamplona eca5a1de15 Matching obtainium's and github's badge 2023-06-29 09:36:55 -04:00
Vitor Pamplona 886442ac52 trying to align obtainium's image on the readme 2023-06-29 09:35:20 -04:00
Vitor Pamplona 8521611008 Increasing borders a bit 2023-06-29 09:32:38 -04:00
Vitor Pamplona df647e4047 Fixing borders 2023-06-29 09:31:44 -04:00
Vitor Pamplona af26026d25 adds borders 2023-06-29 09:29:55 -04:00
Vitor Pamplona 43be55f3d6 reduces the size of the badge 2023-06-29 09:26:32 -04:00
Vitor Pamplona 9b3cb92cb3 Adds obtainium and APK to the links 2023-06-29 09:25:35 -04:00
Vitor Pamplona f2b0ce5fd0 adds obtainium image 2023-06-29 09:24:42 -04:00
Vitor Pamplona 787ca98daf v0.62.8 2023-06-28 19:06:05 -04:00
Vitor Pamplona 71517dc454 Formatting 2023-06-28 18:54:42 -04:00
Vitor Pamplona 949301d58f Mocking Looper to use the checkMainThread in tests 2023-06-28 18:54:33 -04:00
Vitor Pamplona cf8ac4faae Merge remote-tracking branch 'origin/HEAD'
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
2023-06-28 18:32:59 -04:00
Vitor Pamplona ad3aa124fc Performance improvements additional MainThread checks. 2023-06-28 18:32:10 -04:00
Vitor Pamplona 2326aa9f7c Runs the translation update in the main thread 2023-06-28 14:52:01 -04:00
Vitor Pamplona acfd687d74 Avoids blinking the nip05 address 2023-06-28 14:51:34 -04:00
Vitor Pamplona 9b9183395d BugFix: Ignoring case when comparing original and translated versions. 2023-06-28 14:51:02 -04:00
greenart7c3andGitHub 45b4902b2b Merge branch 'vitorpamplona:main' into main 2023-06-28 15:40:36 -03:00
greenart7c3 6056d60d32 added followed tags in profile screen 2023-06-28 15:37:24 -03:00
Vitor PamplonaandGitHub 61af248c09 Merge pull request #479 from ZsZolee/main
Additional Hungarian translation
2023-06-28 11:17:16 -04:00
Vitor PamplonaandGitHub 5a580db1b8 Merge pull request #478 from greenart7c3/main
fix rendering url of highlight note
2023-06-28 11:17:03 -04:00
Vitor PamplonaandGitHub a72ae49e93 Merge pull request #477 from believethehype/cashuredeem
Add copy button to Cashu Rendering, better default error message
2023-06-28 11:16:08 -04:00
Vitor Pamplona 40a01219cc Moves RichText Modifiers to singleton classes. 2023-06-28 11:13:38 -04:00
Vitor Pamplona c652390a9c Updates URLPreview in the main thread. 2023-06-28 11:09:54 -04:00
Vitor Pamplona 0916a97526 Users rememberPagerState instead of just remember 2023-06-28 11:09:19 -04:00
Vitor Pamplona d76ef0a1ca updates firebase to the newest version 2023-06-28 11:06:56 -04:00
ZoltanandGitHub 3a70ce1587 Additional Hungarian translation 2023-06-28 16:52:39 +02:00
greenart7c3 9f96e84378 fix rendering url of highlight note 2023-06-28 07:58:19 -03:00
Believethehype 985771beba Add copy button to Cashu Rendering, better default error message 2023-06-28 11:49:14 +02:00
Vitor PamplonaandGitHub 3967633f77 Merge pull request #475 from davotoula/translation_updates
Updated se/de/cs translations
2023-06-27 15:05:31 -04:00
Vitor Pamplona ee5699c2de v0.62.7 2023-06-27 13:16:28 -04:00
Vitor Pamplona b8f4d9f0bb BugFix on zapraiser completed bar 2023-06-27 13:14:47 -04:00
Vitor Pamplona 8b1de47517 v0.62.6 2023-06-27 12:27:06 -04:00
Vitor Pamplona c6ff514ba2 Logs rendering time of sections of the post 2023-06-27 12:26:57 -04:00
Vitor Pamplona 50f2d18b61 Moves state assignments to the main thread. 2023-06-27 12:05:11 -04:00
Vitor Pamplona fa7ccdfc20 Reduces full screen updates to once a second. 2023-06-27 12:02:55 -04:00
David Kaspar fe02519737 Updated se/de/cs translations 2023-06-27 13:30:37 +01:00
Vitor Pamplona f2e9fbebea v0.62.5 2023-06-26 22:42:42 -04:00
Vitor Pamplona 52af109b4e Minor adjustments in threading 2023-06-26 20:38:06 -04:00
Vitor Pamplona 833e6bc3e1 Moves cashu processing to a state class to account for errors. 2023-06-26 20:17:35 -04:00
Vitor Pamplona 9bed6fdb9a v0.62.4 2023-06-26 18:22:07 -04:00
Vitor Pamplona a4a867ebcd Achieves a bigger space if the hide system bars is placed outside the Dialog. 2023-06-26 18:18:04 -04:00
Vitor Pamplona 4da2f18957 Video Dialog in full screen 2023-06-26 17:44:36 -04:00
Vitor Pamplona 4141a52d51 Allows video dialog on streaming 2023-06-26 15:40:13 -04:00
Vitor Pamplona 4dbc71d577 Removes padding from the live header 2023-06-26 15:39:32 -04:00
Vitor Pamplona 3b5d792562 Extracts Cashu logic into a service class. 2023-06-26 15:27:04 -04:00
Vitor Pamplona dd7f3225c6 Preloading channel from LocalCache 2023-06-26 14:59:18 -04:00
Vitor Pamplona 3477e7b8ba No need to remember Modifier 2023-06-26 14:55:55 -04:00
Vitor Pamplona a3782bb459 Remove image from the live event and use only the author picture in the header. 2023-06-26 14:47:22 -04:00
Vitor Pamplona d4003eee04 Adjusts Profile images to crop and not fit 2023-06-26 14:46:56 -04:00
Vitor Pamplona c9bbb66284 Adjusting logout message. 2023-06-26 14:33:21 -04:00
Vitor PamplonaandGitHub 1498fa9868 Merge pull request #474 from greenart7c3/main
add confirmation dialog before logout
2023-06-26 14:27:15 -04:00
Vitor PamplonaandGitHub 26b493aa1d Merge pull request #473 from believethehype/cashuredeem
Melt cashu tokens in Mint directly instead of using webservice
2023-06-26 14:26:44 -04:00
Vitor Pamplona f9d652f849 Performance Improvements 2023-06-26 14:25:46 -04:00
greenart7c3 30b0ee444f add confirmation dialog before logout 2023-06-26 14:38:44 -03:00
Believethehype bf8341ef58 Merge remote-tracking branch 'upstream/main' into cashuredeem 2023-06-26 18:19:01 +02:00
Believethehype f9f1b0d98e Melt cashu tokens in Mint directly instead of using webservice 2023-06-26 18:18:45 +02:00
Vitor Pamplona 743a4c9d87 Using crossfade to show live activities 2023-06-26 09:12:10 -04:00
Vitor PamplonaandGitHub 275baa7497 Merge pull request #472 from believethehype/cashuredeem
Parse and Show Sat Amount from Cashu tokens
2023-06-26 08:44:19 -04:00
Vitor Pamplona ac1fb879c3 Bugfix for not updating user picture and name after a message arrives 2023-06-26 08:39:46 -04:00
Vitor Pamplona 270ad56450 Aligns LIVE marker to the right in the feed. 2023-06-26 08:38:29 -04:00
Believethehype d155241efc Parse and Show Sat Amount from Cashu tokens 2023-06-26 14:32:08 +02:00
Vitor PamplonaandGitHub d61927cbee Merge pull request #471 from believethehype/cashuredeem
Parse Cashu Tokens and provide link to redeem
2023-06-26 06:50:56 -04:00
Vitor PamplonaandGitHub 996b68f9c6 Merge pull request #470 from Bardesss/patch-1
Update strings.xml (NL)
2023-06-26 06:49:19 -04:00
Believethehype f6913663d1 enable autopay, change parsing 2023-06-26 11:57:31 +02:00
Believethehype 58600e13a8 parse cashu tokens 2023-06-26 11:04:31 +02:00
BardesssandGitHub 54e15ad3cd Update strings.xml (NL)
Updated translation
2023-06-26 08:52:35 +00:00
Vitor Pamplona 777c652bf5 v0.62.3 (TEST) 2023-06-25 19:35:00 -04:00
Vitor Pamplona 7fb1473e21 There is no need to invalidate twice. 2023-06-25 19:34:51 -04:00
Vitor Pamplona 95db1ec84f Typo 2023-06-25 19:34:35 -04:00
Vitor Pamplona 7438973a20 Trying to improve rendering speed of chat messages. 2023-06-25 19:18:32 -04:00
Vitor Pamplona fe31855860 Minor performance improvements in reaction row 2023-06-25 19:18:11 -04:00
Vitor Pamplona d7bce4414d Crossfades changes in Reactions and Zaps 2023-06-25 19:17:48 -04:00
Vitor Pamplona b4a81c137c Removes the need for lifecycle awareness here. 2023-06-25 19:16:41 -04:00
Vitor Pamplona a6390f58d9 Removes debug info 2023-06-25 19:16:15 -04:00
Vitor Pamplona ca3f4c3ec6 Moves MaxWidth for Chat bubbles to Shapes 2023-06-25 16:49:33 -04:00
Vitor Pamplona 53bfd40a31 Simplifies hex check 2023-06-25 16:22:17 -04:00
Vitor Pamplona 798be25c43 BugFix for duplicated notes in the LocalCache 2023-06-25 16:21:36 -04:00
Vitor Pamplona ebe1bcedd0 Moves user msg Synchronization to it's own object. 2023-06-25 16:11:21 -04:00
Vitor Pamplona 4731575b87 Less memory intensive Hex Checker 2023-06-25 12:41:21 -04:00
Vitor Pamplona 383e761372 Moves LocalCache's Synchronized block to Concurrent implementations 2023-06-25 12:40:56 -04:00
Vitor Pamplona 54a95e6a4e Moves synchronized block to inner function 2023-06-25 12:38:08 -04:00
Vitor Pamplona 3105ade755 Moves Shape constants to the Shape class 2023-06-25 11:00:41 -04:00
Vitor Pamplona f651034303 Better starting point for the following observer 2023-06-24 18:08:11 -04:00
Vitor Pamplona 7554dc010c Fixes spacing from last change 2023-06-24 18:07:56 -04:00
Vitor Pamplona 70a962062b Minor code and performance improvements. 2023-06-24 18:03:04 -04:00
Vitor Pamplona 5cc7f3bf2b Correct Immutable class 2023-06-24 15:55:21 -04:00
Vitor Pamplona b040e0ca13 Moves OnlineChecker to a singleton class with cache. 2023-06-24 15:53:22 -04:00
Vitor Pamplona 775094356c Fixes new posts not showing up in the feed. 2023-06-24 10:49:34 -04:00
Vitor Pamplona 0771b179b5 Reducing the need to update the event for every two hours. 2023-06-24 10:36:42 -04:00
Vitor Pamplona 8af307e8bb Migrating the UserProfile page to the new ViewModel structure 2023-06-23 22:00:51 -04:00
Vitor Pamplona e6ec3f1ce4 - Adds picture of the User to the Stream header.
- BugFix for the Click on UserPicture event
2023-06-23 20:44:58 -04:00
Vitor Pamplona b4395e7585 v0.62.2 2023-06-23 18:10:25 -04:00
Vitor Pamplona f8acb26763 Only showing the paid relay icon when the relay is paid. 2023-06-23 18:09:03 -04:00
Vitor Pamplona aa931a4cc5 Improves Logs when the app cannot connect with relays. 2023-06-23 18:02:04 -04:00
Vitor Pamplona e1cf188fbe Displays an error message when it cannot parse the returning json from relays. 2023-06-23 17:57:21 -04:00
Vitor Pamplona 9e57a706ae Moves some of the account observables to maps 2023-06-23 17:57:02 -04:00
Vitor Pamplona 8c73e981b5 formatting 2023-06-23 17:56:25 -04:00
Vitor Pamplona 87f5007fd3 Displays error message on LogCat if fails to login 2023-06-23 17:56:13 -04:00
Vitor Pamplona 5c42bb0282 Removes check in the Main Thread to allow login on Debug 2023-06-23 17:55:51 -04:00
Vitor Pamplona 415252b770 Converts TopBar Observables to Maps. 2023-06-23 17:55:30 -04:00
Vitor Pamplona 4f43d7cde5 More updates to the readme 2023-06-23 15:22:09 -04:00
Vitor Pamplona c819a23f83 updating Readme 2023-06-23 15:16:57 -04:00
Vitor Pamplona 4a00a9a80d v0.62.1 2023-06-23 15:11:08 -04:00
Vitor Pamplona 1f5f6b251c BugFix on publishedAt date for Longform posts 2023-06-23 15:08:26 -04:00
Vitor Pamplona c925b82e86 Stops showing posts in the future 2023-06-23 14:30:26 -04:00
Vitor Pamplona 96a5d1a658 v0.62.0 2023-06-23 14:11:56 -04:00
Vitor Pamplona 36154422f1 Formatting 2023-06-23 13:41:04 -04:00
Vitor Pamplona 5b2a878f5d Moving the NIP98 event to it's own class. 2023-06-23 13:40:57 -04:00
Vitor Pamplona 976d96ef35 formatting 2023-06-23 12:27:08 -04:00
Vitor PamplonaandGitHub 94b31fe5f9 Merge pull request #466 from believethehype/main
added NIP98 Support, Added nostrcheck.me Image hoster
2023-06-23 12:26:51 -04:00
Vitor PamplonaandGitHub cd2a59ae43 Merge pull request #465 from greenart7c3/main
show icon for paid relays in the relay list
2023-06-23 12:18:23 -04:00
Vitor Pamplona e8f5ef3aa3 Removes check in the main thread to avoid mocking Android's threading system 2023-06-23 12:10:46 -04:00
Vitor Pamplona 887fc33073 Minor performance adjustments across the app. 2023-06-23 12:02:27 -04:00
Vitor Pamplona c24ba18207 Avoids calling out errors in LogCat when they don't exist. 2023-06-23 12:01:46 -04:00
Vitor Pamplona 5137009f5f Improves rendering time of the Channel header 2023-06-23 11:36:50 -04:00
Vitor Pamplona ba6bd6a2d2 Making more classes stable for performance reasons. 2023-06-23 11:36:08 -04:00
Believethehype bfc16d0878 added some wait time before updating status 2023-06-23 16:39:40 +02:00
Believethehype 94c41e0c6d added NIP98 Support, Added nostrcheck.me hoster 2023-06-23 16:10:04 +02:00
Vitor Pamplona 33454cc2f8 Fixes UTF-32 Reactions 2023-06-23 09:21:34 -04:00
Believethehype d969a483a9 Merge remote-tracking branch 'upstream/main' 2023-06-23 12:50:22 +02:00
greenart7c3 a51104fd82 show icon for paid relays in the relay list 2023-06-23 06:51:21 -03:00
Vitor Pamplona a536388123 Avoids crashing due to illegal zap address 2023-06-22 19:39:55 -04:00
Vitor Pamplona c7cb4b7015 Adds logs to Init functions of classes to keep a close eye on. 2023-06-22 17:41:55 -04:00
Vitor Pamplona fbb1b59e34 Adds checks for the main thread when parsing NIP30 2023-06-22 17:41:11 -04:00
Vitor Pamplona 7d7f047f55 removes unused variable 2023-06-22 15:23:16 -04:00
Vitor Pamplona e9e0b0f57f Adds more checks for not in the main thread 2023-06-22 15:23:04 -04:00
Vitor Pamplona baf0372537 Moves the image border modifier to an app variable. 2023-06-22 15:16:17 -04:00
Vitor Pamplona f62716a226 formatting 2023-06-22 15:11:55 -04:00
Vitor Pamplona 9b1c47f4a5 Checks main thread in preferences 2023-06-22 14:53:10 -04:00
Vitor Pamplona a4dd3aa488 Moves checks to run to outside the LaunchedEffect 2023-06-22 14:30:22 -04:00
Vitor Pamplona 9dbe7379cd Fixes reaction row space. 2023-06-22 14:25:18 -04:00
Vitor Pamplona ad353d2d1a Generalizes the SensitivityWarning to Note. 2023-06-22 12:04:11 -04:00
Vitor Pamplona 32b2b1ace8 Uses derivedOf to protect from updates with the same content 2023-06-22 12:03:52 -04:00
Vitor Pamplona 13710dde06 No need to go into a Synchronized block to get a user object here. 2023-06-22 11:49:49 -04:00
Vitor Pamplona 915ddd0bad Improving relay options rendering performance. 2023-06-22 11:49:27 -04:00
Vitor Pamplona 52ef356c06 Minor performance improvements throughout the code 2023-06-22 11:39:56 -04:00
Vitor Pamplona 69481a046b v0.61.4 2023-06-21 14:56:44 -04:00
Vitor Pamplona 157802142c Reorganizing Relay Info Page 2023-06-21 14:51:44 -04:00
Vitor PamplonaandGitHub 54462f8d26 Merge pull request #462 from greenart7c3/relay_information
Relay information dialog
2023-06-21 14:03:34 -04:00
Vitor PamplonaandGitHub fcf612d22a Merge pull request #461 from ZsZolee/main
Zapraiser Hungarian translation
2023-06-21 14:02:46 -04:00
Vitor Pamplona e8eea4be25 Removes old image proxy classes 2023-06-21 12:47:53 -04:00
Vitor Pamplona cce9d6cf68 Improves rendering performance of Chat Screens 2023-06-21 11:57:49 -04:00
greenart7c3 143f58fd68 add string resources 2023-06-21 11:38:34 -03:00
Vitor Pamplona 6fe66986be Improves rendering of chat messages when scrolling. 2023-06-21 10:23:49 -04:00
greenart7c3 68e8abecb2 fix lints 2023-06-21 11:23:06 -03:00
greenart7c3 bb698a351e fix build after rebasing 2023-06-21 10:47:27 -03:00
greenart7c3 4506ff9c93 created dialog to show relay information 2023-06-21 10:43:34 -03:00
ZoltanandGitHub f88a4d56c7 Zapraiser Hungarian translation 2023-06-21 10:12:48 +02:00
Vitor Pamplona fbe0f584af v0.61.3 2023-06-20 19:00:46 -04:00
Vitor Pamplona e28f26d471 BugFix for opening the video chat with video in full screen until it loads. 2023-06-20 18:59:21 -04:00
Vitor Pamplona 80a9a2a712 Fix margins when summary is not available. 2023-06-20 18:58:56 -04:00
Vitor Pamplona 2a47cd4fd1 BugFix for too many streams (fix in max = 2 for now) 2023-06-20 18:58:41 -04:00
Vitor Pamplona ce207aed39 Refreshing home when follows change, including Live activities. 2023-06-20 18:27:21 -04:00
Vitor Pamplona 244e3f3b29 v0.61.2 2023-06-20 17:13:24 -04:00
Vitor Pamplona 6a44d283f9 - Restructures Chat Message Rendering
- Adds Sensitive Content protections for chat renderings.
- Adjusts padding of the channel headers.
2023-06-20 16:53:36 -04:00
Vitor Pamplona 6addce3d20 Simplifying some of the chat rendering process 2023-06-20 15:16:29 -04:00
Vitor Pamplona 8aae101ef6 Adds live activities to the main feed. 2023-06-20 13:54:47 -04:00
Vitor Pamplona d1bbdef5c4 v0.61.1 2023-06-19 22:01:30 -04:00
Vitor Pamplona b78967f8be Screen diet 2023-06-19 21:58:12 -04:00
Vitor Pamplona 01a2f746d0 Adds space between the play button and the user's name in private messages 2023-06-19 21:10:01 -04:00
Vitor Pamplona d063c2990c Moves the label to the bottom of the video 2023-06-19 20:58:38 -04:00
Vitor Pamplona e31ee031cb Removes the cache from streaming files to make sure the stream keeps playing. 2023-06-19 20:53:38 -04:00
Vitor Pamplona 8fc489cc59 Formatting 2023-06-19 20:43:55 -04:00
Vitor Pamplona ffb2a19963 Adds Zaps and Reactions to the Zap Activity author 2023-06-19 20:43:48 -04:00
Vitor Pamplona b271c1775c BugFix for not updating Notifications quickly. 2023-06-19 20:30:13 -04:00
Vitor Pamplona f937e95b35 v0.61.0 2023-06-19 18:36:32 -04:00
Vitor Pamplona 1613e79860 Support for live chats in streaming events. 2023-06-19 18:34:52 -04:00
Vitor Pamplona dd40b4f205 Formatting 2023-06-19 16:24:58 -04:00
Vitor Pamplona 2ad9401ee6 Downloads last posts from the author. 2023-06-19 16:23:19 -04:00
Vitor Pamplona 69ade2f6e6 Merge remote-tracking branch 'origin/HEAD' 2023-06-19 16:02:23 -04:00
Vitor Pamplona 4006e62a82 Renders Channel headers, NIP94 and NIP95 in the thread head. 2023-06-19 16:01:51 -04:00
Vitor Pamplona ec88a8b157 Updates readme 2023-06-19 16:00:23 -04:00
Vitor PamplonaandGitHub 128211ecd2 Merge pull request #459 from greenart7c3/main
add toast message to show relay icon description
2023-06-19 15:32:46 -04:00
Vitor Pamplona 8f53537ebc v0.60.2 2023-06-19 14:43:34 -04:00
Vitor Pamplona 58c93e0cce - Redesign feed invalidations to account for changes in the follow list
- Redesign scroll to the top implementation to avoid using arguments in the navigator.
2023-06-19 14:30:15 -04:00
greenart7c3 c35b5e3703 add toast message for relay icons 2023-06-19 07:58:25 -03:00
Vitor Pamplona be8848fb1b Restructures Stable elements for minor performance gains. 2023-06-18 18:57:21 -04:00
Vitor Pamplona a3efd15b95 v0.60.1 2023-06-18 14:43:59 -04:00
Vitor Pamplona a1930c232b Formatting 2023-06-18 14:42:26 -04:00
Vitor Pamplona 58a4fb18ae BugFixes for NPE 2023-06-18 14:40:46 -04:00
Vitor Pamplona be4870da1a Support for Per-post Zap-raisers. 2023-06-18 14:24:52 -04:00
Vitor Pamplona d2f6317d5c v0.60.0 2023-06-17 21:09:11 -04:00
Vitor Pamplona 05fb408842 - Adds Support for Kind 16
- Migrates citations to `e` when they were supposed to be `a`
- Removes original tags from Reposts
-
2023-06-17 21:08:33 -04:00
Vitor Pamplona 611305a406 Basic Support for live activities 2023-06-17 18:37:34 -04:00
Vitor Pamplona 5b856af19b - Activates support for m3u8 streaming
- BugFix on restarting the video when pressing mute.
2023-06-17 18:07:06 -04:00
Vitor Pamplona ce5106684f Moves the last seen time saved per route to an account property in order to speed up loading and facilitate multithreading in the app. 2023-06-17 17:28:14 -04:00
Vitor Pamplona f0fe2dfc17 Don't move the feed up when new follow list is updated 2023-06-17 15:56:46 -04:00
Vitor Pamplona fb5fad5d02 Fixing background color for messages when it's considered a new notification. 2023-06-17 10:53:37 -04:00
Vitor Pamplona 6314e3fcb2 Formatting 2023-06-16 19:59:12 -04:00
Vitor Pamplona da62760fd7 Moving static sizes to the Shape class 2023-06-16 19:57:25 -04:00
Vitor Pamplona e3eae80d4c Increasing performance of the follow/follower count 2023-06-16 19:11:14 -04:00
Vitor Pamplona 3cd0828cba Bugfix for home feed scrolling to the top when a new follower updates the cache. 2023-06-16 18:47:10 -04:00
Vitor Pamplona 806b86f950 Testing a release build script with deamon 2023-06-16 16:36:20 -04:00
Vitor Pamplona c931e2cbee v0.59.1 2023-06-16 16:21:23 -04:00
Vitor Pamplona 4e57eed5d8 Adds Name Playback on messages as well. 2023-06-16 16:18:06 -04:00
Vitor Pamplona 8e725259eb BugFix for Messages being marked as read from Notifications. 2023-06-16 16:17:42 -04:00
Vitor Pamplona b48d2df8d2 v0.59.0 2023-06-16 15:23:44 -04:00
Vitor Pamplona dbf34ae457 Text to speech for usernames 2023-06-16 15:22:44 -04:00
Vitor Pamplona 74be724835 Hide reposts of posts from people you already follow if the repost time is within the hour of the original post (not that far in the feed). 2023-06-16 14:31:52 -04:00
Vitor Pamplona 012c7a7105 BugFix for feed not updating after change in follows. 2023-06-16 14:30:22 -04:00
Vitor Pamplona e50dff3b62 BigFix for some reposts not showing up in the feed. 2023-06-16 14:15:53 -04:00
Vitor Pamplona ad5a4d0b59 v0.58.3 2023-06-15 18:51:39 -04:00
Vitor Pamplona 3116ea1ad9 Fixes reaction sizes and alignments. 2023-06-15 12:21:44 -04:00
Vitor Pamplona 72e474c9ec Bug fix for when deleting the reaction event reverting to the old like heart. 2023-06-15 09:37:09 -04:00
Vitor Pamplona fe9e85c282 v0.58.2 2023-06-14 20:58:51 -04:00
Vitor Pamplona 01ac90fbec BugFix for active like all the time 2023-06-14 20:55:46 -04:00
Vitor Pamplona dc079941e7 v0.58.1 2023-06-14 20:28:47 -04:00
Vitor Pamplona 9a7a94cb77 Cropping long reactions to two chars for emojis 2023-06-14 20:27:46 -04:00
Vitor Pamplona a44703cb49 v0.58.0 2023-06-14 16:50:54 -04:00
Vitor Pamplona 52bd6b4443 Custom reactions 2023-06-14 16:49:51 -04:00
Believethehype 279894f9d7 Merge remote-tracking branch 'upstream/main' 2023-06-14 22:31:07 +02:00
Vitor Pamplona a9e1ce5aec Removing modifiers from remembers and into global singletons. 2023-06-14 14:36:10 -04:00
Vitor Pamplona 639fdb174d BugFix Add -> And 2023-06-14 14:32:55 -04:00
Vitor Pamplona 3cd02ee6cf Merge remote-tracking branch 'origin/HEAD' 2023-06-14 13:49:10 -04:00
Vitor Pamplona 52199fe281 Starts to use getXIfExists functions to preload objects 2023-06-14 13:48:58 -04:00
Vitor Pamplona 5616fa52d1 Simplifies URL Preview call 2023-06-14 13:48:35 -04:00
Vitor PamplonaandGitHub 7241c8bd9f Merge pull request #454 from ZsZolee/main
Small Hungarian translation
2023-06-14 10:55:16 -04:00
ZoltanandGitHub 991131f8cd Small Hungarian translation 2023-06-14 09:35:36 +02:00
Vitor Pamplona a24c95070c v0.57.0 2023-06-13 17:16:20 -04:00
Vitor Pamplona a346d31554 Finally adds markers to kind 1s 2023-06-13 17:08:50 -04:00
Vitor Pamplona 9b5c7ad23f - Moves zaps from a Map<Request,Response> to a List<CustomClass> to avoid recompositions due to the use of the unstable Pair object
- Creates the class ZapAmountCommentNotification to be used instead of the unstable Triple class.
2023-06-13 16:33:52 -04:00
Vitor Pamplona a01a060abc Uses animateItemPlacement to show new Notes and update the size of current ones. 2023-06-13 16:31:23 -04:00
Vitor Pamplona 1cd39e0d8f Removes the use of by on the quick action menu's state because it was triggering recompositions 2023-06-13 16:30:37 -04:00
Vitor Pamplona 305ecc6a2e New update removes access to the backQueue 2023-06-13 16:29:21 -04:00
Vitor Pamplona 858f94ae74 Moves icon sizes in the bottom row to remember functions 2023-06-13 16:28:42 -04:00
Vitor Pamplona fc224d99e3 Updates dependencies. 2023-06-13 10:52:29 -04:00
Vitor Pamplona b8584964d1 Activates build cache 2023-06-13 10:47:08 -04:00
Vitor Pamplona fbd0c96a8a Merge remote-tracking branch 'origin/HEAD' 2023-06-12 16:58:32 -04:00
Vitor Pamplona e2251f9c86 Removing the minimum size for rows in the feed due to Hidden posts from the user's block list. 2023-06-12 16:58:20 -04:00
Vitor Pamplona eaaacbf218 Turns NIP-05 verification symbol into a State variable to help correctly rendering updates. 2023-06-12 16:56:24 -04:00
Vitor Pamplona 243fbeed44 Remembers some variables on NoteCompose 2023-06-12 16:55:51 -04:00
Vitor Pamplona d62bab1b08 Makes relay badge list in chatroom Immutable 2023-06-12 16:55:27 -04:00
Vitor Pamplona 70e068ed17 Breaks down user renderer compositions 2023-06-12 16:55:08 -04:00
Vitor Pamplona 824e8795d5 Formatting 2023-06-12 16:54:45 -04:00
Vitor Pamplona f768a09bd8 Removes unused variable from Translation results 2023-06-12 16:54:15 -04:00
Vitor PamplonaandGitHub 1a69a5a4c7 Merge pull request #449 from greenart7c3/main
add option to opt-out from automatic spam and report filters
2023-06-12 16:37:03 -04:00
Vitor PamplonaandGitHub b9cc3781bb Merge pull request #451 from licaon-kter/patch-1
Typo fix
2023-06-12 11:10:22 -04:00
Licaon_KterandGitHub 97964ed65c Typo fix 2023-06-12 15:04:25 +00:00
Vitor Pamplona 42a1d157bf Fixes draft note in privacy.md 2023-06-12 10:25:03 -04:00
Vitor Pamplona e27efbc9c2 Privacy Policy:
- Removes restrictions of use from the F-Droid version given that F-Droid is taking that legal liability.
- Adds information about the Image Servers offered through the app.
- Adds privacy warnings from the readme to this document.
2023-06-12 10:23:29 -04:00
greenart7c3andGitHub 57a5d0b618 Merge branch 'main' into main 2023-06-12 07:39:30 -03:00
greenart7c3 8f668cb5fb separated spam and report filter options 2023-06-12 07:38:32 -03:00
Vitor Pamplona df5ea6f741 Adjusts the size and alignment of relay icons in the video feed 2023-06-11 10:59:18 -04:00
Vitor Pamplona 2e6847df1c v0.56.5 2023-06-10 21:20:57 -04:00
Vitor Pamplona 76a49d6556 Fixing upstream issue with images on markdown. 2023-06-10 21:18:30 -04:00
Vitor Pamplona 59fe3ccf3a Updates readme 2023-06-10 21:18:08 -04:00
Vitor Pamplona ba884bd1aa Improving markdown support 2023-06-10 21:17:50 -04:00
Vitor Pamplona e98bb76ee0 Support for NIP-14 2023-06-10 21:16:55 -04:00
Vitor Pamplona e08bf3eb97 If compression fails, tries to upload with the original file 2023-06-10 21:15:19 -04:00
Vitor Pamplona 9ef02d63a0 Refactors color objects to avoid recreating them on-demand. 2023-06-10 10:08:06 -04:00
Vitor Pamplona 7007796497 v0.56.4 2023-06-09 18:16:42 -04:00
Vitor Pamplona 9f030ffbf8 BugFix for crashing on start up due to creating two caches for videos. 2023-06-09 18:16:03 -04:00
Vitor Pamplona 9ca172e0fc v0.56.3 2023-06-09 17:12:29 -04:00
Vitor Pamplona 360758a2f3 Adds a networking state monitoring services to reconnect when the phone disconnects from relays. 2023-06-09 17:08:35 -04:00
Vitor Pamplona ffe6f4b55c Fixes F-droid version for mutable colors as background. 2023-06-09 16:43:47 -04:00
Vitor Pamplona 267b5e474c Adds another check to make sure the event does not exist in the local memory before notifying. 2023-06-09 16:41:14 -04:00
Vitor Pamplona bf6bc5ba68 Moves the recomposition due to background color change to each compose node. 2023-06-09 16:35:56 -04:00
Vitor Pamplona 1efc4c91e2 Reactors base note rendering to wrap the QuickAction menu 2023-06-09 14:07:38 -04:00
Vitor Pamplona a898196571 Refactoring shapes to the appropriate file. 2023-06-09 13:45:46 -04:00
Vitor Pamplona c158c7fc3d Moves the clipboard starters to their own compositions. 2023-06-09 13:19:14 -04:00
Vitor Pamplona e3d83fe39a improving the speed of the highlighter rendering 2023-06-09 11:27:27 -04:00
Vitor Pamplona 5327e1ddb2 Final touches on borders 2023-06-09 11:27:02 -04:00
Vitor Pamplona a2ad13c54a More spacing refinements for notes. 2023-06-09 10:49:10 -04:00
Vitor Pamplona 816a22ec69 More adjustments to borders and spacing between screen elements for different event types 2023-06-09 10:25:06 -04:00
Vitor Pamplona 8a99dc2db4 Removes unnecessary imports. 2023-06-09 10:03:11 -04:00
Vitor Pamplona f9cfe1479b - Breaks down the Note Composition stack further
- Fixes some border issues between multiple note types
- Aligns Quick Actions to the center of the note.
2023-06-09 10:02:56 -04:00
Vitor Pamplona 5d8aa7bb58 Refactoring variable name in Reactions 2023-06-09 10:00:37 -04:00
greenart7c3 f0638b1766 add option to opt-out from automatic spam and report filters 2023-06-09 08:28:05 -03:00
Vitor Pamplona b3e2b78415 v0.56.2 2023-06-08 18:16:53 -04:00
Vitor Pamplona d0094cc781 Removing duplicated ID protections from Notifications. Looks like we removed all the issues here. 2023-06-08 17:47:10 -04:00
Vitor Pamplona cb77298f52 Correctly processing multiple matching via find and not match 2023-06-08 17:46:47 -04:00
Vitor Pamplona 24532c8f20 Looks like there is never nothing to restore, so removing this. 2023-06-08 16:31:22 -04:00
Vitor Pamplona b641684377 Uses default thread to wait for a 250 milliseconds before navigating away from the video upload page. 2023-06-08 16:31:04 -04:00
Vitor Pamplona 9589f68028 Moves sending message on channel from the main thread to the IO thread 2023-06-08 16:30:29 -04:00
Vitor Pamplona 8b852d32e4 BugFix for notifications sometimes not reassembling when pressing the bottom button. 2023-06-08 16:28:48 -04:00
Vitor Pamplona 5b864fa1d9 Adds a second rule for short messages in the spam filter to avoid recognizing "@Bot, command" messages spam. 2023-06-08 15:52:35 -04:00
Vitor Pamplona 8e91987c31 Refactors search screen to make the SearchTextField @Stable 2023-06-08 15:51:46 -04:00
Vitor Pamplona 1c3b34a9c7 Moves the creation of the VideoPlayer to its own composition 2023-06-08 14:22:22 -04:00
Vitor PamplonaandGitHub 5a44f8d663 Merge pull request #448 from TonyGiorgio/onyx-hashtag
Add onyx hashtag
2023-06-08 12:30:36 -04:00
Vitor Pamplona 2ab7e6b24d v0.56.1 2023-06-08 12:18:06 -04:00
Vitor PamplonaandGitHub e9f8fd6cec Merge pull request #447 from TonyGiorgio/weed-hashtag
Add weed hashtag
2023-06-08 12:17:31 -04:00
Tony Giorgio 8d7060afe6 Add onyx hashtag 2023-06-08 11:15:47 -05:00
Vitor Pamplona 784f983746 Image and Video Compression 2023-06-08 12:14:26 -04:00
Tony Giorgio ecc4141f50 Add weed hashtag 2023-06-08 11:12:20 -05:00
Vitor PamplonaandGitHub b964c6a7fa Merge pull request #444 from ZsZolee/main
Adding one small Hungarian translation
2023-06-07 19:17:03 -04:00
Vitor PamplonaandGitHub 5d37e337bc Merge branch 'main' into main 2023-06-07 19:16:56 -04:00
Vitor Pamplona d9815b669b v0.56.0 2023-06-07 18:55:34 -04:00
Vitor Pamplona 6906993cc3 Support to per-post display reactions 2023-06-07 18:50:29 -04:00
Vitor Pamplona 705309cb8a v0.55.4 2023-06-07 17:17:46 -04:00
Vitor Pamplona 89bdc9dd58 Adds support for RelaySet event types. 2023-06-07 17:16:48 -04:00
Vitor Pamplona ac0103a53e v0.55.3 2023-06-07 15:57:31 -04:00
Vitor Pamplona b2c31d3b46 Avoid reusing SHA256 object because of a memory leak in multithreading 2023-06-07 15:49:15 -04:00
Vitor Pamplona 931bd58c51 Removes main thread checks on Verification due to the need to mock this method when running tests. 2023-06-07 13:59:12 -04:00
Vitor Pamplona 6a7d6a843d Minor updates to remember states in composition 2023-06-07 12:26:32 -04:00
Vitor Pamplona eaae672a30 Further breaks down the bottom bar to facilitate recompositions. 2023-06-07 12:25:49 -04:00
Vitor Pamplona ccfbfeffe2 Moves ChatroomList's New Feed to an Additive filter. 2023-06-07 12:25:11 -04:00
Vitor Pamplona eb62da8d26 Refactors the top bar relay updates. 2023-06-07 12:24:49 -04:00
Vitor Pamplona d334c6b2e1 BugFix for the Notification dot not immediately disappearing after marking all as read. 2023-06-07 12:24:24 -04:00
Vitor Pamplona 90f0e4aad1 Refactoring duplicated code in Clickable Route 2023-06-07 12:23:30 -04:00
Vitor Pamplona fabf52ffd7 Moves channel name to a remember state 2023-06-07 12:22:53 -04:00
Vitor Pamplona c499c4baec Adds more main thread checks to make sure slow procedures are run outside the main thread. 2023-06-07 12:08:13 -04:00
Vitor Pamplona 4bd19c3e3d Deletes hiddenUsers method from account to avoid misuse since it needs a blocking load from cache. 2023-06-07 12:07:03 -04:00
Vitor Pamplona 6a47f5aa3b Moves notification's scroll to top animation to an IO thread. 2023-06-07 12:06:18 -04:00
Vitor Pamplona 03d1754498 Moves collection events to an IO thread. 2023-06-07 12:05:45 -04:00
Vitor Pamplona e9374370b9 Moves author creation for Addressable notes to outside a @synchronized block to avoid deadlocks. 2023-06-07 12:04:50 -04:00
Vitor Pamplona 88a5e309bd Removes kinds from the filter since relays only allow 20 options. In this way, we get everything and filter it locally. 2023-06-07 12:03:44 -04:00
ZoltanandGitHub f7a292598e Adding one small Hungarian translation 2023-06-07 11:00:21 +02:00
Vitor Pamplona cff8684347 updating firebase version 2023-06-06 16:47:28 -04:00
Vitor Pamplona b9654d164f Further splitting the translation class into more methods to simplify recompositions 2023-06-06 16:47:14 -04:00
Vitor Pamplona c10db10430 Moving unstable parameters into stable ones to reduce recompositions. 2023-06-06 15:51:43 -04:00
Vitor Pamplona 50f3f7f176 Fixes a search bug that wouldn't update relay filters correctly with the search terms 2023-06-06 15:50:21 -04:00
Vitor Pamplona 49c3334509 activates compose reports 2023-06-06 15:49:52 -04:00
Vitor Pamplona e75f70985a upping memory requirements for the build 2023-06-06 15:49:29 -04:00
Vitor Pamplona e956f51f19 v0.55.2 2023-06-05 18:22:26 -04:00
Vitor Pamplona 165678f118 formatting 2023-06-05 18:22:09 -04:00
Vitor Pamplona d32d2da280 Increases the speed of the Zap Tab in Profiles. 2023-06-05 18:19:23 -04:00
Vitor PamplonaandGitHub d6a6a52821 Merge pull request #441 from davotoula/german_translation
Added German translation
2023-06-05 16:39:50 -04:00
Vitor Pamplona dfb697975f v0.55.1 2023-06-05 16:06:32 -04:00
Vitor Pamplona c0c0fd9e6d Fixes tests 2023-06-05 16:06:25 -04:00
Vitor Pamplona 5bdcdc6f4a - Refactoring feeds use of the pullrefresh
- Adding more places to check if in the main thread.
2023-06-05 15:33:16 -04:00
David Kaspar 102ab63673 Added German translation 2023-06-04 20:41:48 +01:00
Vitor Pamplona b34c1f98d5 Marks some methods to never run on the Main Thread 2023-06-04 12:17:19 -04:00
Vitor Pamplona 74b05e3d6a Simplifying recompositions of NIP05 2023-06-04 12:17:02 -04:00
Vitor Pamplona 972bce426a No need to display replying information anymore. 2023-06-04 11:56:53 -04:00
Vitor Pamplona 3de57d6ae2 Moves memory cleanup to an IO thread 2023-06-04 11:56:39 -04:00
Vitor Pamplona 5695a82c20 Fixes: https://github.com/vitorpamplona/amethyst/issues/438 2023-06-04 11:45:34 -04:00
Vitor Pamplona e171120836 Moves relay state to a new watcher 2023-06-03 21:46:26 -04:00
Vitor Pamplona abdfe531f7 v0.55.0 2023-06-03 18:50:12 -04:00
Vitor Pamplona ca90866877 Keeping the position of the feed in navigation 2023-06-03 17:02:09 -04:00
Vitor Pamplona 450fa2b70d Removing some unnecessary code 2023-06-03 16:21:02 -04:00
Vitor Pamplona 93c9ffeb02 adds a background to the app's icon in recommendation 2023-06-03 16:01:39 -04:00
Vitor Pamplona 2e5e546e7d Making sure video does not restart when pressing the mute button 2023-06-03 16:01:20 -04:00
Vitor Pamplona 00e7add001 Adds support for seeing NIP-89 recommendations on User's profile. 2023-06-03 12:39:06 -04:00
Vitor Pamplona e28cd2212e v0.54.2 2023-06-02 22:46:45 -04:00
Vitor Pamplona 8f38353271 Shows comment and amount of zaps sent directly to the profile. 2023-06-02 22:45:58 -04:00
Vitor Pamplona a41ee653d8 Moves reaction counts to a thread 2023-06-02 22:14:21 -04:00
Vitor Pamplona 839be06f62 Removes unnecessary mapping 2023-06-02 21:59:17 -04:00
Vitor Pamplona bbef06de59 Moves UserFeed to MutableLists 2023-06-02 21:52:22 -04:00
Vitor Pamplona 515159f18c Ignores unloaded notes in the MultiComposeSet renderer. 2023-06-02 21:29:12 -04:00
Vitor Pamplona e7b7ebbbdf Adds a new thread call for the notification of reaction rows 2023-06-02 21:28:50 -04:00
Vitor Pamplona 5965d9a3bb Moves profile watchers to their own compose functions 2023-06-02 21:28:30 -04:00
Vitor Pamplona 31959721b1 Moves reward calculations to the IO thread 2023-06-02 21:27:57 -04:00
Vitor Pamplona 8e16daaaa3 Only refreshes follow lists once per notification event. 2023-06-02 21:27:43 -04:00
Vitor Pamplona e9fd47a536 Runs bottom's new notification refresher in the IO thread 2023-06-02 21:27:11 -04:00
Vitor Pamplona 6191ef9a6b Removes old notification cards 2023-06-02 20:36:01 -04:00
Vitor Pamplona 05aa12ebac - Removing outdated version of the FlowRow
- Improving performance of custom emojis
- Improving performance of DropDown menus
2023-06-02 20:30:49 -04:00
Vitor Pamplona 42428c5e0f Moves channel checks to happen before a Channel object is loaded. 2023-06-01 19:56:31 -04:00
Vitor Pamplona 1a3685c5ab v0.54.1 2023-06-01 16:07:08 -04:00
Vitor Pamplona fff892e1c8 Making sure pollnote view models are unique 2023-06-01 16:03:22 -04:00
Vitor Pamplona 99c30816c6 Simplifying observables 2023-06-01 16:01:35 -04:00
Vitor Pamplona 92c61f317b Restructuring follows observables 2023-06-01 15:16:03 -04:00
Vitor Pamplona b0e50e0c00 Fixes Translations 2023-06-01 09:11:47 -04:00
Vitor Pamplona f065dcfc5a v0.54.0 2023-05-31 22:13:54 -04:00
Vitor Pamplona 91f34000f5 Improves rendering performance of the ExoPlayer 2023-05-31 22:09:18 -04:00
Vitor Pamplona 93e2af3c1f formatting 2023-05-31 21:32:53 -04:00
Vitor Pamplona 6072e5977e Reducing recompositions on images. 2023-05-31 21:32:46 -04:00
Vitor Pamplona 8575ea018d Improving rendering of the text 2023-05-31 20:43:57 -04:00
Vitor Pamplona ed919ae531 UrlPreviewer using Main thread :( 2023-05-31 20:08:30 -04:00
Vitor Pamplona 3eb832c4e0 Improves Poll Rendering speed. 2023-05-31 20:08:07 -04:00
Vitor Pamplona a57ca2a79d Using the correct immutable class 2023-05-31 16:33:23 -04:00
Vitor Pamplona 07a681c002 Merge remote-tracking branch 'origin/HEAD' 2023-05-31 16:31:55 -04:00
Vitor PamplonaandGitHub cb561d432c Merge pull request #433 from davotoula/add_per_app_language_preferences
Add support for per-app languange preferences
2023-05-31 16:31:19 -04:00
Vitor Pamplona d942c12627 Restructuring ViewModels 2023-05-31 16:30:11 -04:00
David Kaspar a466f33b4e Add support for per-app languange preferences 2023-05-31 09:34:21 +01:00
Vitor PamplonaandGitHub 3744f90e87 Merge pull request #432 from davotoula/translation_sv
Added Swedish translation
2023-05-30 16:04:34 -04:00
Vitor Pamplona 1cc0f4bf80 Don't notify twice 2023-05-30 16:02:45 -04:00
David Kaspar dec409c209 moved previous Swedish translation to a resource file
Corrected and added to Swedish translation
2023-05-30 20:58:50 +01:00
Vitor Pamplona c664397b11 Starts video with the audio on/off based on global mute variable 2023-05-30 13:58:21 -04:00
Vitor Pamplona bc1b71e9bf Improves reply info rendering 2023-05-30 13:58:00 -04:00
Vitor Pamplona a42606af0d v0.53.7 2023-05-30 12:07:23 -04:00
Vitor Pamplona b301e1d79b Removes sorting of the blocked list because it needs to load all metadata to sort correctly. 2023-05-30 12:07:15 -04:00
Vitor Pamplona 5ab94e8a1c - Renders the report's comment in the user profile report tab
- Allows reply, zap, boost and like the report itself.
- Shows report messages on notifications
2023-05-30 12:03:33 -04:00
Believethehype ffa03e1d0d Merge remote-tracking branch 'upstream/main' 2023-05-30 09:17:15 +02:00
Vitor Pamplona 7ad982ec71 v0.53.6 2023-05-29 21:44:11 -04:00
Vitor Pamplona 030064e112 Sorting blocked users by name 2023-05-29 21:43:59 -04:00
Vitor Pamplona d9a392d2c1 BugFix for not loading the security filter screen 2023-05-29 21:40:44 -04:00
Vitor Pamplona 97cd1c5e30 v0.53.5 2023-05-29 21:15:10 -04:00
Vitor Pamplona 24a6d8a155 Restart Video Cache when chancing Tor Setup 2023-05-29 21:14:06 -04:00
Vitor Pamplona ea185147b3 v0.54.4 2023-05-29 19:47:02 -04:00
Vitor Pamplona f2db2b88c2 Improves rendering of Markdown summaries 2023-05-29 19:46:54 -04:00
Vitor Pamplona 6ea0972151 Fixes bug with rendering of markdown with user citations. 2023-05-29 19:20:33 -04:00
Believethehype 719f5c2ac6 Merge remote-tracking branch 'upstream/main' 2023-05-24 17:30:10 +02:00
Believethehype baa3a21dd2 Merge remote-tracking branch 'upstream/main' 2023-05-09 21:04:08 +02:00
Believethehype 8cb84c2395 Merge remote-tracking branch 'upstream/main' 2023-05-05 17:52:38 +02:00
Believethehype ee52e725d9 Merge remote-tracking branch 'upstream/main' 2023-05-03 00:35:41 +02:00
Believethehype f3e0c31087 Merge remote-tracking branch 'upstream/main' 2023-05-02 00:13:32 +02:00
Believethehype 92ccdfc55f Merge remote-tracking branch 'upstream/main' 2023-05-01 18:59:38 +02:00
Believethehype 0528e59572 Merge remote-tracking branch 'upstream/main' 2023-05-01 13:42:26 +02:00
Believethehype 0ab3219115 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-05-01 13:42:05 +02:00
Believethehype 724cfddb69 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-26 19:22:44 +02:00
Believethehype 7fcc28a320 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-23 18:09:28 +02:00
Believethehype 94051f178b Merge remote-tracking branch 'upstream/main' 2023-04-22 04:06:40 +02:00
Believethehype 8e4f339aa1 Merge remote-tracking branch 'upstream/main' 2023-04-21 15:21:24 +02:00
Believethehype 425aa69691 Merge branch 'main' of https://github.com/believethehype/amethyst 2023-04-12 19:08:46 +02:00
Believethehype 1c65334b91 Delete footstr.xml 2023-04-09 23:39:57 +02:00
Believethehype 726d04f0f4 Update HashtagIcon.kt 2023-04-09 23:39:32 +02:00
Believethehype 9d1ab5a9b8 introduce zap types, implemented non-zaps in custom zap dialog
Non-Zaps allow to tip the author of a note without using the zap mechanism (no record on the note)
2023-04-09 23:37:23 +02:00
Believethehype 09bad79870 #footstr 2023-04-09 05:31:31 +02:00
373 changed files with 33149 additions and 9502 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
run: ./gradlew assembleRelease --stacktrace
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
+41 -6
View File
@@ -1,13 +1,48 @@
# Amethyst Privacy Policy & Terms of Use
# Amethyst Privacy Policy and Terms of Use
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
## Privacy Policy
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Effective as of Jun 12, 2023
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open-source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
The app may collect a per-device token, your public key, and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts are only stored locally on the device when it's required for the functionality and performance of Amethyst. This data is strictly confidential and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
Amethyst offers a few options to upload pictures and videos in order to post them online. You can choose the server at your discretion. Similar to relays, such services are independent of the app and have their own privacy policy and terms of use.
### Privacy with Relays services
Your internet protocol (IP) address is exposed to the relays you connect to. If you want to improve your privacy, consider utilizing a service that masks your IP address (e.g. a VPN) from trackers online.
The relay can also see which public keys you are using and what information you are requesting from the network. Your public key is tied to your IP address and your relay filters.
Relays have all your data in raw text. They know your IP, your name, your location (guessed from IP), your pub key, all your contacts, and other relays, and can read every action you do (post, like, boost, quote, report, etc) with the exception of the content inside Private Zaps and Private DMs.
While the content of direct messages (DMs) is only visible to you, and your DM Nostr counterparty, everyone can see when you and your counterparty are DM-ing each other. Image uploads in the DM screen use one of the chosen image servers and simply paste the image link on the DM text. Your uploaded pictures are available to anyone with that direct link.
### Visibility & Permanence of Your Content on Nostr Relays
#### Information Visibility
Content that you share can be shared with other relays by any user of the network.
Information that you share is publicly visible to anyone reading from relays that have your information. Your information may also be visible to Nostr users who do not share relays with you.
#### Information Permanence
Information shared on Nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
## Terms of Use
### For versions downloaded from Google's Play Store
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary rights, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
### For versions downloaded from F-Droid
We do not control the distribution of the application in F-Droid. Legal matters should be resolved between the user and F-Droid.
## Other Notes
We reserve the right to modify this Privacy Policy and Terms of Use at any time. Any modifications to this document will be effective upon our posting of the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy and Terms of Use indicates your acceptance of the terms of the modified Privacy Policy and/or Terms of Use.
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
+29 -8
View File
@@ -4,6 +4,11 @@
Amethyst brings the best social network to your Android phone. Just insert your Nostr private key and start posting.
[<img src="./docs/design/obtainium.png"
alt="Get it on Obtaininum"
height="80">](https://github.com/ImranR98/Obtainium)
[<img src="https://github.com/machiav3lli/oandbackupx/raw/034b226cea5c1b30eb4f6a6f313e4dadcbb0ece4/badge_github.png" alt="Get it on GitHub"
height="80">](https://github.com/vitorpamplona/amethyst/releases)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/com.vitorpamplona.amethyst/)
@@ -11,15 +16,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=com.vitorpamplona.amethyst)
Or get the latest APK from the [Releases Section](https://github.com/vitorpamplona/amethyst/releases/latest).
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (likes NIP-25, boost, reply)
- [x] Replies and mentions (NIP-10)
- [x] Reactions (NIP-25)
- [x] Reposts (NIP-18)
- [x] Image/Video/Url/LnInvoice Previews
- [x] View Threads
- [x] Private Messages (NIP-04)
@@ -56,19 +61,29 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [x] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Local Database
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
- [ ] Expiration Support (NIP-40)
- [ ] Infinity Scroll
@@ -183,6 +198,12 @@ Information that you share is publicly visible to anyone reading from relays tha
## Information Permanence ##
Information shared on nostr should be assumed permanent for privacy purposes. There is no way to guarantee deleting or editing any content once posted.
# Screenshots
| Home Feed | Messages | Live Streams | Notifications |
|-------------------------------------------|----------------------------------------------|-------------------------------------------------|--------------------------------------------------------|
| ![Home Feed](./docs/screenshots/home.png) | ![Messages](./docs/screenshots/messages.png) | ![Live Streams](./docs/screenshots/replies.png) | ![Notifications](./docs/screenshots/notifications.png) |
# Contributing
[Issues](https://github.com/vitorpamplona/amethyst/issues) and [pull requests](https://github.com/vitorpamplona/amethyst/pulls) are very welcome.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+25 -14
View File
@@ -13,13 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 187
versionName "0.53.3"
versionCode 250
versionName "0.68.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
}
buildTypes {
@@ -115,7 +116,7 @@ dependencies {
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -128,20 +129,19 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.1'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'org.jsoup:jsoup:1.16.1'
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
implementation "com.google.android.exoplayer:exoplayer:${exoplayer_version}"
// important for proxy / tor
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
implementation "com.google.android.exoplayer:extension-okhttp:${exoplayer_version}"
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -153,8 +153,6 @@ dependencies {
// create blurhash
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
@@ -166,9 +164,17 @@ dependencies {
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// Markdown
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
//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"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -177,7 +183,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation platform('com.google.firebase:firebase-bom:32.1.1')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -189,8 +195,13 @@ dependencies {
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Video compression lib
implementation 'com.github.AbedElazizShe:LightCompressor:1.2.3'
// Image compression lib
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
File diff suppressed because it is too large Load Diff
@@ -11,17 +11,17 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translatePT(text: String, translateTo: String): String? {
fun translateTo(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))
assertEquals(null, expected, translateTo(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
val translated = translatePT(input, translateTo)!!
val translated = translateTo(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@@ -86,4 +86,17 @@ class TranslationsTest {
"en"
)
}
@Test
fun testJapaneseTranslationsOfUrl() {
assertTranslateContains(
"https://youtu.be/wMYFmCDy_Eg",
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
"\n" +
"https://youtu.be/wMYFmCDy_Eg\n" +
"\n" +
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
"en"
)
}
}
@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -10,8 +12,8 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) = ExpandableRichTextViewer(
+19 -2
View File
@@ -4,17 +4,33 @@
<queries>
<package android:name="org.torproject.android"/>
</queries>
<!-- Doesn't require a camera -->
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- To Upload media (newer devices) -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<!-- To Upload media (old devices) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- To read QRs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- To read NFCs that contain nostr:<NIP19> -->
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
<!-- To know receive notifications when the app connects/disconnects from the web -->
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Old permission to access media -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />
<application
@@ -30,6 +46,7 @@
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
tools:targetApi="33">
<activity
android:name=".ui.MainActivity"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 22 KiB

@@ -3,12 +3,14 @@ package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
@@ -30,6 +32,7 @@ import java.util.Locale
private const val DEBUG_PLAINTEXT_PREFERENCES = false
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
@Immutable
data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean = false
@@ -41,17 +44,20 @@ private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
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 REACTION_CHOICES = "reactionChoices"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -59,6 +65,14 @@ private object PrefKeys {
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -198,23 +212,29 @@ object LocalPreferences {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
putString(PrefKeys.LAST_READ_PER_ROUTE, gson.toJson(account.lastReadPerRoute))
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
@@ -222,6 +242,47 @@ object LocalPreferences {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
val globalPrefs = encryptedPreferences()
globalPrefs.edit().apply {
if (account.settings.automaticallyShowImages == null) {
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
}
if (account.settings.automaticallyStartPlayback == null) {
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
}
if (account.settings.automaticallyShowUrlPreview == null) {
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
fun updateTheme(theme: Int) {
encryptedPreferences().edit().apply {
putInt(PrefKeys.THEME, theme)
}.apply()
}
fun getTheme(): Int {
encryptedPreferences().apply {
return getInt(PrefKeys.THEME, 0)
}
}
fun getPreferredLanguage(): String {
var language = ""
encryptedPreferences().apply {
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
}
return language
}
fun loadFromEncryptedStorage(): Account? {
@@ -235,6 +296,7 @@ object LocalPreferences {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
@@ -246,12 +308,18 @@ object LocalPreferences {
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val reactionChoices = gson.fromJson<List<String>>(
getString(PrefKeys.REACTION_CHOICES, "[]"),
object : TypeToken<List<String>>() {}.type
).ifEmpty { listOf("+") } ?: listOf("+")
val defaultZapType = gson.fromJson(
getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
object : TypeToken<LnZapEvent.ZapType>() {}.type
@@ -304,46 +372,77 @@ object LocalPreferences {
} else {
null
}
val filterSpam = getBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, true)
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
val lastReadPerRoute = try {
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
gson.fromJson(
it,
object : TypeToken<Map<String, Long>>() {}.type
) as Map<String, Long>
} ?: mapOf()
} catch (e: Throwable) {
e.printStackTrace()
mapOf()
}
val settings = Settings()
encryptedPreferences().apply {
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
} else {
null
}
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
} else {
null
}
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
} else {
null
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
val a = Account(
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList,
proxy,
proxyPort,
showSensitiveContent
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
languagePreferences = languagePreferences,
translateTo = translateTo,
zapAmountChoices = zapAmountChoices,
reactionChoices = reactionChoices,
defaultZapType = defaultZapType,
defaultFileServer = defaultFileServer,
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
backupContactList = latestContactList,
proxy = proxy,
proxyPort = proxyPort,
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute,
settings = settings
)
return a
}
}
fun saveLastRead(route: String, timestampInSecs: Long) {
encryptedPreferences(currentAccount()).edit().apply {
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
}.apply()
}
fun loadLastRead(route: String): Long {
encryptedPreferences(currentAccount()).run {
return getLong(PrefKeys.LAST_READ(route), 0)
}
}
fun migrateSingleUserPrefs() {
if (currentAccount() != null) return
@@ -1,53 +0,0 @@
package com.vitorpamplona.amethyst
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object NotificationCache {
// TODO: This must be account-based
val lastReadByRoute = mutableMapOf<String, Long>()
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadByRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadByRoute.put(route, timestampInSecs)
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences.saveLastRead(route, timestampInSecs)
live.invalidateData()
}
}
}
fun load(route: String): Long {
var lastTime = lastReadByRoute[route]
if (lastTime == null) {
lastTime = LocalPreferences.loadLastRead(route)
lastReadByRoute[route] = lastTime
}
return lastTime
}
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
}
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(NotificationState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
}
class NotificationState(val cache: NotificationCache)
@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst
object OptOutFromFilters {
var warnAboutPostsWithReports: Boolean = true
var filterSpamFromStrangers: Boolean = true
fun start(warnAboutReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnAboutReports
filterSpamFromStrangers = filterSpam
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import android.util.Log
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
@@ -12,9 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
@@ -22,6 +27,7 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
object ServiceManager {
private var account: Account? = null
@@ -31,11 +37,15 @@ object ServiceManager {
start(context)
}
@Synchronized
fun start(context: Context) {
Log.d("ServiceManager", "Starting Relay Services")
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
Coil.setImageLoader {
ImageLoader.Builder(context).components {
if (Build.VERSION.SDK_INT >= 28) {
@@ -50,6 +60,9 @@ object ServiceManager {
.build()
}
// Initializes video cache.
VideoCache.init(context.applicationContext)
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
@@ -58,11 +71,14 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
@@ -72,12 +88,18 @@ object ServiceManager {
}
fun pause() {
Log.d("ServiceManager", "Pausing Relay Services")
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrGlobalDataSource.stop()
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()
@@ -19,6 +19,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
@Synchronized
fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -29,6 +30,13 @@ object VideoCache {
exoDatabaseProvider
)
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} else {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
import com.vitorpamplona.amethyst.service.model.*
@@ -49,33 +50,75 @@ val KIND3_FOLLOWS = " All Follows "
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var followingCommunities: Set<String> = setOf(),
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var reactionChoices: List<String> = listOf("+"),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int,
var showSensitiveContent: Boolean? = null
var showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var settings: Settings = Settings()
) {
var transientHiddenUsers: Set<String> = setOf()
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
val liveLastRead: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: Boolean?
) {
settings.automaticallyStartPlayback = automaticallyStartPlayback
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowUrlPreview(
automaticallyShowUrlPreview: Boolean?
) {
settings.automaticallyShowUrlPreview = automaticallyShowUrlPreview
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: Boolean?
) {
settings.automaticallyShowImages = automaticallyShowImages
live.invalidateData()
saveable.invalidateData()
}
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnReports
filterSpamFromStrangers = filterSpam
OptOutFromFilters.start(warnAboutPostsWithReports, filterSpamFromStrangers)
if (!filterSpamFromStrangers) {
transientHiddenUsers = setOf()
}
live.invalidateData()
saveable.invalidateData()
}
fun userProfile(): User {
return userProfileCache ?: run {
@@ -86,11 +129,11 @@ class Account(
}
fun followingChannels(): List<Channel> {
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
fun followingCommunities(): List<AddressableNote> {
return followingCommunities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it) }
}
fun isWriteable(): Boolean {
@@ -133,8 +176,8 @@ class Account(
}
}
fun reactionTo(note: Note): List<Note> {
return note.reactedBy(userProfile(), "+")
fun reactionTo(note: Note, reaction: String): List<Note> {
return note.reactedBy(userProfile(), reaction)
}
fun hasBoosted(note: Note): Boolean {
@@ -145,20 +188,33 @@ class Account(
return note.boostedBy(userProfile())
}
fun hasReacted(note: Note): Boolean {
return note.hasReacted(userProfile(), "+")
fun hasReacted(note: Note, reaction: String): Boolean {
return note.hasReacted(userProfile(), reaction)
}
fun reactTo(note: Note) {
fun reactTo(note: Note, reaction: String) {
if (!isWriteable()) return
if (hasReacted(note)) {
if (hasReacted(note, reaction)) {
// has already liked this note
return
}
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val event = ReactionEvent.create(emojiUrl, it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
return
}
}
note.event?.let {
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -309,6 +365,12 @@ class Account(
}
}
fun createHTTPAuthorization(url: String, method: String, body: String? = null): HTTPAuthorizationEvent? {
if (!isWriteable()) return null
return HTTPAuthorizationEvent.create(url, method, body, loggedIn.privKey!!)
}
fun boost(note: Note) {
if (!isWriteable()) return
@@ -318,9 +380,15 @@ class Account(
}
note.event?.let {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
if (it.kind() == 1) {
val event = RepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
} else {
val event = GenericRepostEvent.create(it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
}
@@ -443,6 +511,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)
@@ -472,6 +541,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)
@@ -487,7 +557,11 @@ class Account(
mentions: List<User>?,
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>
) {
if (!isWriteable()) return
@@ -503,6 +577,10 @@ class Account(
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
replyingTo = replyingTo,
root = root,
directMentions = directMentions,
privateKey = loggedIn.privKey!!
)
@@ -520,7 +598,8 @@ class Account(
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null
) {
if (!isWriteable()) return
@@ -540,14 +619,15 @@ class Account(
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -561,27 +641,49 @@ class Account(
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
fun sendLiveMessage(message: String, toChannel: ATag, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val repliesToHex = replyTo?.map { it.idHex }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = LiveActivitiesChatMessageEvent.create(
message = message,
activity = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = mentions?.map { it.pubkeyHex }
val signedEvent = PrivateDmEvent.create(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
recipientPubKey = toUser.pubkey(),
publishedRecipientPubKey = toUser.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = zapRaiserAmount,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -609,6 +711,51 @@ class Account(
joinChannel(event.id)
}
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().filter { it != emojiListEvent.address() },
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = if (usersEmojiList.event == null) {
EmojiPackSelectionEvent.create(
listOf(emojiListEvent.address()),
loggedIn.privKey!!
)
} else {
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) {
return
}
EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().plus(emojiListEvent.address()),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return
@@ -799,6 +946,20 @@ class Account(
saveable.invalidateData()
}
fun joinCommunity(idHex: String) {
followingCommunities = followingCommunities + idHex
live.invalidateData()
saveable.invalidateData()
}
fun leaveCommunity(idHex: String) {
followingCommunities = followingCommunities - idHex
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
live.invalidateData()
@@ -842,12 +1003,24 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultDiscoveryFollowList(name: String) {
defaultDiscoveryFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
saveable.invalidateData()
}
fun changeReactionTypes(newTypes: List<String>) {
reactionChoices = newTypes
live.invalidateData()
saveable.invalidateData()
}
fun changeZapPaymentRequest(newServer: Nip47URI?) {
zapPaymentRequest = newServer
live.invalidateData()
@@ -1004,10 +1177,10 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
if (newContactList == null || newContactList.tags.isEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
if (backupContactList?.id != newContactList.id) {
backupContactList = newContactList
saveable.invalidateData()
}
@@ -1062,12 +1235,19 @@ class Account(
}
fun isAcceptable(user: User): Boolean {
if (!warnAboutPostsWithReports) {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() // if user has not reported this post
}
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
private fun isAcceptableDirect(note: Note): Boolean {
if (!warnAboutPostsWithReports) {
return note.reportsBy(userProfile()).isEmpty()
}
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
@@ -1076,11 +1256,15 @@ class Account(
return user.pubkeyHex in followingKeySet()
}
fun isFollowing(user: HexKey): Boolean {
return user in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
isAcceptableDirect(note) &&
(
note.event !is RepostEvent ||
(note.event !is RepostEvent && note.event !is GenericRepostEvent) ||
(note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
@@ -1088,7 +1272,7 @@ class Account(
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports = if (note.event is RepostEvent) {
val innerReports = if (note.event is RepostEvent || note.event is GenericRepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
} else {
emptyList()
@@ -1125,6 +1309,19 @@ class Account(
live.invalidateData()
}
fun markAsRead(route: String, timestampInSecs: Long) {
val lastTime = lastReadPerRoute[route]
if (lastTime == null || timestampInSecs > lastTime) {
lastReadPerRoute = lastReadPerRoute + Pair(route, timestampInSecs)
saveable.invalidateData()
liveLastRead.invalidateData()
}
}
fun loadLastRead(route: String): Long {
return lastReadPerRoute[route] ?: 0
}
fun registerObservers() {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
@@ -1135,7 +1332,9 @@ class Account(
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
GlobalScope.launch(Dispatchers.IO) {
updateContactListTo(userProfile().latestContactList)
}
}
// imports transient blocks due to spam.
@@ -1143,8 +1342,7 @@ class Account(
GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
if (it.pubkeyHex != userProfile().pubkeyHex && it.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
@@ -1155,6 +1353,7 @@ class Account(
}
init {
Log.d("Init", "Account")
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
@@ -1166,14 +1365,14 @@ class Account(
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Default) {
if (hasActiveObservers()) {
refresh()
}
}
private val bundler = BundledUpdate(300, Dispatchers.Default)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
if (hasActiveObservers()) {
refresh()
}
}
}
fun refresh() {
@@ -2,8 +2,12 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.OptOutFromFilters
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import kotlinx.coroutines.Dispatchers
@@ -14,13 +18,21 @@ class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event, relay: Relay?): Boolean {
checkNotInMainThread()
if (!OptOutFromFilters.filterSpamFromStrangers) return false
val idHex = event.id
// if short message, ok
// The idea here is to avoid considering repeated "GM" messages spam.
if (event.content.length < 50) return false
// if the message is actually short but because it cites a user/event, the nostr: string is really long, make it ok.
// The idea here is to avoid considering repeated "@Bot, command" messages spam, while still blocking repeated "lnbc..." invoices or fishing urls
if (event.content.length < 180 && Nip19.nip19regex.matcher(event.content).find()) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
@@ -28,18 +40,13 @@ class AntiSpamFilter {
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${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
logOffender(hash, event)
liveSpam.invalidateData()
}
liveSpam.invalidateData()
return true
}
@@ -49,20 +56,35 @@ class AntiSpamFilter {
return false
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
@Synchronized
private fun logOffender(hashCode: Int, event: Event) {
if (spamMessages.get(hashCode) == null) {
spamMessages.put(hashCode, Spammer(event.pubKey, setOf(recentMessages[hashCode], event.id)))
} else {
val spammer = spamMessages.get(hashCode)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id
}
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
@Stable
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}
}
}
}
@@ -1,28 +1,109 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
class Channel(val idHex: String) {
var creator: User? = null
@Stable
class PublicChatChannel(idHex: String) : Channel(idHex) {
var info = ChannelCreateEvent.ChannelData(null, null, null)
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
override fun toBestDisplayName(): String {
return info.name ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info.about
}
override fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return super.profilePicture()
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
class LiveActivitiesChannel(val address: ATag) : Channel(address.toTag()) {
var info: LiveActivitiesEvent? = null
override fun idNote() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
fun address() = address
fun updateChannelInfo(creator: User, channelInfo: LiveActivitiesEvent, updatedAt: Long) {
this.info = channelInfo
super.updateChannelInfo(creator, updatedAt)
}
override fun toBestDisplayName(): String {
return info?.title() ?: super.toBestDisplayName()
}
override fun summary(): String? {
return info?.summary()
}
override fun profilePicture(): String? {
return info?.image()?.ifBlank { null }
}
override fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info?.title(), info?.summary())
.filter { it.contains(prefix, true) }.isNotEmpty()
}
}
@Stable
abstract class Channel(val idHex: String) {
var creator: User? = null
var updatedMetadataAt: Long = 0
val notes = ConcurrentHashMap<HexKey, Note>()
fun id() = Hex.decode(idHex)
fun idNote() = id().toNote()
fun idDisplayNote() = idNote().toShortenHex()
open fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun idDisplayNote() = idNote().toShortenHex()
fun toBestDisplayName(): String {
return info.name ?: idDisplayNote()
open fun toBestDisplayName(): String {
return idDisplayNote()
}
open fun summary(): String? {
return null
}
open fun profilePicture(): String? {
return creator?.profilePicture()
}
open fun updateChannelInfo(creator: User, updatedAt: Long) {
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
}
fun addNote(note: Note) {
@@ -33,23 +114,11 @@ class Channel(val idHex: String) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
this.updatedMetadataAt = updatedAt
live.invalidateData()
fun removeNote(noteHex: String) {
notes.remove(noteHex)
}
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) info.picture = null
return info.picture
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(info.name, info.about)
.filter { it.startsWith(prefix, true) }.isNotEmpty()
}
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
@@ -74,24 +143,35 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}
}
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
}
override fun onInactive() {
super.onInactive()
NostrSingleChannelDataSource.remove(channel.idHex)
if (channel is PublicChatChannel) {
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
}
}
@@ -1,24 +1,31 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}
}
@Immutable
class HashtagIcon(
val icon: Int,
val description: String,
@@ -1,9 +1,10 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.HexValidator
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
@@ -12,8 +13,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@@ -23,12 +22,6 @@ import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
object LocalCache {
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>(5000)
@@ -40,63 +33,98 @@ object LocalCache {
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
checkNotInMainThread()
if (isValidHex(key)) {
return getOrCreateUser(key)
}
return null
}
@Synchronized
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
return users[key] ?: run {
val answer = User(key)
users.put(key, answer)
answer
val newObject = User(key)
users.putIfAbsent(key, newObject) ?: newObject
}
}
fun getUserIfExists(key: String): User? {
if (key.isEmpty()) return null
return users.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return addressables[key]
}
fun getNoteIfExists(key: String): Note? {
return addressables[key] ?: notes[key]
}
fun getChannelIfExists(key: String): Channel? {
return channels[key]
}
fun checkGetOrCreateNote(key: String): Note? {
checkNotInMainThread()
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
if (isValidHexNpub(key)) {
return getOrCreateNote(key)
if (isValidHex(key)) {
val note = getOrCreateNote(key)
val noteEvent = note.event
return if (noteEvent is AddressableEvent) {
// upgrade to the latest
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
} else {
note
}
}
return null
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
val answer = Note(idHex)
notes.put(idHex, answer)
answer
checkNotInMainThread()
return notes.get(idHex) ?: run {
val newObject = Note(idHex)
notes.putIfAbsent(idHex, newObject) ?: newObject
}
}
fun checkGetOrCreateChannel(key: String): Channel? {
if (isValidHexNpub(key)) {
return getOrCreateChannel(key)
checkNotInMainThread()
if (isValidHex(key)) {
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
}
val aTag = ATag.parse(key, null)
if (aTag != null) {
return getOrCreateChannel(aTag.toTag()) {
LiveActivitiesChannel(aTag)
}
}
return null
}
private fun isValidHexNpub(key: String): Boolean {
return try {
Hex.decode(key).toNpub()
true
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
false
}
private fun isValidHex(key: String): Boolean {
if (key.isBlank()) return false
if (key.contains(":")) return false
return HexValidator.isHex(key)
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
fun getOrCreateChannel(key: String, channelFactory: (String) -> Channel): Channel {
checkNotInMainThread()
return channels[key] ?: run {
val answer = Channel(key)
channels.put(key, answer)
answer
val newObject = channelFactory(key)
channels.putIfAbsent(key, newObject) ?: newObject
}
}
@@ -114,34 +142,34 @@ object LocalCache {
}
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
val answer = AddressableNote(key)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toTag(), answer)
answer
val newObject = AddressableNote(key)
addressables.putIfAbsent(key.toTag(), newObject) ?: newObject
}
}
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
}
return note
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
val newUser = try {
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
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null) {
oldUser.updateUserInfo(newUserMetadata, 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)}")
@@ -161,9 +189,15 @@ object LocalCache {
}
fun consume(event: PeopleListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -202,7 +236,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
@@ -216,9 +250,15 @@ object LocalCache {
}
fun consume(event: LongTextNoteEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
@@ -268,7 +308,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
@@ -281,34 +321,174 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: PinListEvent) {
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
note.loadEvent(event, author, emptyList())
if (note.event?.id() == event.id()) return
refreshObservers(note)
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
val channel = getOrCreateChannel(note.idHex) {
LiveActivitiesChannel(note.address)
} as? LiveActivitiesChannel
channel?.updateChannelInfo(author, event, event.createdAt)
refreshObservers(note)
}
}
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: RelaySetEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: AudioTrackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event != null) return
if (note.event?.id() == event.id()) return
note.loadEvent(event, author, emptyList())
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
refreshObservers(note)
}
}
fun consume(event: BadgeDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -320,9 +500,15 @@ object LocalCache {
}
fun consume(event: BadgeProfilesEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
@@ -357,6 +543,46 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: AppDefinitionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event != null) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: AppRecommendationEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@@ -388,8 +614,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, repliesTo)
@@ -429,8 +654,13 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
deleteNote.channelHex()?.let {
channels[it]?.removeNote(deleteNote)
}
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
channels[it.toTag()]?.removeNote(deleteNote)
}
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
@@ -478,6 +708,59 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: GenericRepostEvent) {
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 repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, repliesTo)
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
}
refreshObservers(note)
}
fun consume(event: CommunityPostApprovalEvent) {
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 communities = event.communities()
val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = communities.map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, eventsApproved)
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
}
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id)
@@ -492,26 +775,8 @@ object LocalCache {
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
if (
event.content == "" ||
event.content == "+" ||
event.content == "\u2764\uFE0F" || // red heart
event.content == "\uD83E\uDD19" || // call me hand
event.content == "\uD83D\uDC4D" // thumbs up
) {
// Counts the replies
repliesTo.forEach {
it.addReaction(note)
}
}
if (event.content == "!" || // nostr_console hide.
event.content == "\u26A0\uFE0F" // Warning sign
) {
// Counts the replies
repliesTo.forEach {
it.addReport(note)
}
repliesTo.forEach {
it.addReaction(note)
}
refreshObservers(note)
@@ -541,9 +806,15 @@ object LocalCache {
mentions.forEach {
it.addReport(note)
}
}
repliesTo.forEach {
it.addReport(note)
} else {
repliesTo.forEach {
it.addReport(note)
}
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
}
}
refreshObservers(note)
@@ -551,7 +822,9 @@ object LocalCache {
fun consume(event: ChannelCreateEvent) {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val oldChannel = getOrCreateChannel(event.id) {
PublicChatChannel(it)
}
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
@@ -566,7 +839,9 @@ object LocalCache {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
}
@@ -577,10 +852,13 @@ object LocalCache {
// new event
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)
if (oldChannel is PublicChatChannel) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
}
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
@@ -628,7 +906,48 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach {
it.addReply(note)
}
refreshObservers(note)
}
fun consume(event: LiveActivitiesChatMessageEvent, relay: Relay?) {
val activityId = event.activity() ?: return
val channel = getOrCreateChannel(activityId.toTag()) {
LiveActivitiesChannel(activityId)
}
val note = getOrCreateNote(event.id)
channel.addNote(note)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
if (antiSpam.isSpam(event, relay)) {
relay?.let {
it.spamCounter++
}
return
}
val replyTo = event.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, replyTo)
// Counts the replies
replyTo.forEach {
@@ -839,6 +1158,8 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
@@ -853,6 +1174,8 @@ object LocalCache {
}
fun findNotesStartingWith(text: String): List<Note> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
@@ -871,13 +1194,14 @@ object LocalCache {
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.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
}
}
fun findChannelsStartingWith(text: String): List<Channel> {
checkNotInMainThread()
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
@@ -906,6 +1230,8 @@ object LocalCache {
}
fun pruneOldAndHiddenMessages(account: Account) {
checkNotInMainThread()
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
@@ -919,11 +1245,13 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
}
}
fun pruneHiddenMessages(account: Account) {
checkNotInMainThread()
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
}.flatten()
@@ -951,6 +1279,8 @@ object LocalCache {
}
fun pruneContactLists(userAccount: Account) {
checkNotInMainThread()
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
@@ -969,11 +1299,22 @@ object LocalCache {
live.invalidateData(newNote)
}
fun consume(event: Event, relay: Relay?) {
if (!event.hasValidSignature()) return
fun verifyAndConsume(event: Event, relay: Relay?) {
checkNotInMainThread()
if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
}
try {
when (event) {
is AppDefinitionEvent -> consume(event)
is AppRecommendationEvent -> consume(event)
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
@@ -984,16 +1325,28 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ClassifiedsEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
verifyAndConsume(it, relay)
}
consume(event)
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LiveActivitiesEvent -> consume(event, relay)
is LiveActivitiesChatMessageEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -1007,10 +1360,17 @@ object LocalCache {
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is RelaySetEvent -> consume(event)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
consume(it, relay)
verifyAndConsume(it, relay)
}
consume(event)
}
is GenericRepostEvent -> {
event.containedPost()?.let {
verifyAndConsume(it, relay)
}
consume(event)
}
@@ -1026,12 +1386,13 @@ object LocalCache {
}
}
@Stable
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
private val bundler = BundledInsert<Note>(1000, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
@@ -4,11 +4,15 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -17,7 +21,6 @@ import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -28,7 +31,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
override fun createdAt(): Long? {
if (event == null) return null
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
return minOf(publishedAt, lastCreatedAt)
}
}
@Stable
@@ -42,7 +52,7 @@ open class Note(val idHex: String) {
// These fields are updated every time an event related to this note is received.
var replies = setOf<Note>()
private set
var reactions = setOf<Note>()
var reactions = mapOf<String, Set<Note>>()
private set
var boosts = setOf<Note>()
private set
@@ -68,13 +78,20 @@ open class Note(val idHex: String) {
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return (event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
}
fun channel(): Channel? {
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
return if (event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel()
?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
null
}
}
open fun address(): ATag? = null
@@ -99,7 +116,7 @@ open class Note(val idHex: String) {
*/
fun replyLevelSignature(cachedSignatures: MutableMap<Note, String> = mutableMapOf()): String {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return "/" + formattedDateTime(createdAt() ?: 0) + ";"
}
@@ -112,7 +129,7 @@ open class Note(val idHex: String) {
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (replyTo == null || replyTo.isEmpty()) {
if (event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()) {
return 0
}
@@ -137,8 +154,21 @@ open class Note(val idHex: String) {
liveSet?.boosts?.invalidateData()
}
fun removeReaction(note: Note) {
reactions = reactions - note
liveSet?.reactions?.invalidateData()
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
}
}
}
fun removeReport(deleteNote: Note) {
@@ -183,6 +213,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
@@ -194,6 +225,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
@@ -204,8 +236,14 @@ open class Note(val idHex: String) {
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note))
liveSet?.reactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note)
liveSet?.reactions?.invalidateData()
}
}
@@ -244,8 +282,63 @@ open class Note(val idHex: String) {
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
fun publicZapAuthors(): Set<User> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author
}.toSet()
}
fun publicZapAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
}
fun reactionAuthors(): Set<User> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author }
}.flatten().toSet()
}
fun reactionAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun replyAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author?.pubkeyHex
}.toSet()
}
fun replyAuthors(): Set<User> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author
}.toSet()
}
fun boostAuthors(): Set<User> {
// Zaps who the requester was the user
return boosts.mapNotNull {
it.author
}.toSet()
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
@@ -270,6 +363,10 @@ open class Note(val idHex: String) {
}.flatten()
}
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
@@ -340,7 +437,7 @@ open class Note(val idHex: String) {
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
val dayAgo = TimeUtils.oneDayAgo()
return reports.isNotEmpty() ||
(
author?.reports?.values?.any {
@@ -350,7 +447,14 @@ open class Note(val idHex: String) {
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
return (
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
replyTo?.size == 0
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
@@ -362,18 +466,57 @@ open class Note(val idHex: String) {
}
fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions.filter { it.author == loggedIn && it.event?.content() == content }
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
fun reactedBy(loggedIn: User): List<String> {
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
fun moveAllReferencesTo(note: AddressableNote) {
// migrates these comments to a new version
replies.forEach {
note.addReply(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reactions.forEach {
it.value.forEach {
note.addReaction(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
boosts.forEach {
note.addBoost(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reports.forEach {
it.value.forEach {
note.addReport(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
zaps.forEach {
note.addZap(it.key, it.value)
it.key.replyTo = it.key.replyTo?.updated(this, note)
it.value?.replyTo = it.value?.replyTo?.updated(this, note)
}
replyTo = null
replies = emptySet()
reactions = emptyMap()
boosts = emptySet()
reports = emptyMap()
zaps = emptyMap()
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
@@ -414,14 +557,18 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(NoteState(note))
}
}
}
override fun onActive() {
@@ -0,0 +1,75 @@
package com.vitorpamplona.amethyst.model
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
baseNote.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
// Breaks these searchers down to avoid the memory use of creating multiple lists
baseNote.replies.forEach { reply ->
reply.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.boosts.forEach { boost ->
boost.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.zaps.forEach { zapPair ->
zapPair.key.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.reactions.forEach { reactionSet ->
reactionSet.value.forEach { reaction ->
reaction.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
}
}
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val set = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
return set
}
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val mySet = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
baseNote.replies.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
baseNote.boosts.forEach {
it.replyTo?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
return mySet
}
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet)
return list.size
}
}
@@ -0,0 +1,250 @@
package com.vitorpamplona.amethyst.model
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
class RelayInformation(
val name: String?,
val description: String?,
val pubkey: String?,
val contact: String?,
val supported_nips: List<Int>?,
val supported_nip_extensions: List<String>?,
val software: String?,
val version: String?,
val limitation: RelayInformationLimitation?,
val relay_countries: List<String>?,
val language_tags: List<String>?,
val tags: List<String>?,
val posting_policy: String?,
val payments_url: String?,
val fees: RelayInformationFees?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformation::class.java, RelayInformationSerializer())
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformation = gson.fromJson(json, RelayInformation::class.java)
}
}
class RelayInformationFee(
val amount: Int?,
val unit: String?,
val period: Int?,
val kinds: List<Int>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFee::class.java, RelayInformationFeeSerializer())
.create()
fun fromJson(json: String): RelayInformationFee = gson.fromJson(json, RelayInformationFee::class.java)
}
}
private class RelayInformationFeeSerializer : JsonSerializer<RelayInformationFee> {
override fun serialize(
src: RelayInformationFee,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("amount", src.amount)
addProperty("unit", src.unit)
addProperty("period", src.period)
add(
"kinds",
JsonArray().also { kinds ->
src.kinds?.forEach { kind ->
kinds.add(
kind
)
}
}
)
}
}
}
class RelayInformationFees(
val admission: List<RelayInformationFee>?,
val subscription: List<RelayInformationFee>?,
val publication: List<RelayInformationFee>?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationFees::class.java, RelayInformationFeesSerializer())
.create()
fun fromJson(json: String): RelayInformationFees = gson.fromJson(json, RelayInformationFees::class.java)
}
}
private class RelayInformationFeesSerializer : JsonSerializer<RelayInformationFees> {
override fun serialize(
src: RelayInformationFees,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
add(
"admission",
JsonArray().also { admissions ->
src.admission?.forEach { admission ->
admissions.add(
admission.toString()
)
}
}
)
add(
"publication",
JsonArray().also { publications ->
src.publication?.forEach { publication ->
publications.add(
publication.toString()
)
}
}
)
add(
"subscription",
JsonArray().also { subscriptions ->
src.subscription?.forEach { subscription ->
subscriptions.add(
subscription.toString()
)
}
}
)
}
}
}
class RelayInformationLimitation(
val max_message_length: Int?,
val max_subscriptions: Int?,
val max_filters: Int?,
val max_limit: Int?,
val max_subid_length: Int?,
val min_prefix: Int?,
val max_event_tags: Int?,
val max_content_length: Int?,
val min_pow_difficulty: Int?,
val auth_required: Boolean?,
val payment_required: Boolean?
) {
companion object {
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(RelayInformationLimitation::class.java, RelayInformationLimitationSerializer())
.create()
fun fromJson(json: String): RelayInformationLimitation = gson.fromJson(json, RelayInformationLimitation::class.java)
}
}
private class RelayInformationLimitationSerializer : JsonSerializer<RelayInformationLimitation> {
override fun serialize(
src: RelayInformationLimitation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("max_message_length", src.max_message_length)
addProperty("max_subscriptions", src.max_subscriptions)
addProperty("max_filters", src.max_filters)
addProperty("max_limit", src.max_limit)
addProperty("max_subid_length", src.max_subid_length)
addProperty("min_prefix", src.min_prefix)
addProperty("max_event_tags", src.max_event_tags)
addProperty("max_content_length", src.max_content_length)
addProperty("min_pow_difficulty", src.min_pow_difficulty)
addProperty("auth_required", src.auth_required)
addProperty("payment_required", src.payment_required)
}
}
}
private class RelayInformationSerializer : JsonSerializer<RelayInformation> {
override fun serialize(
src: RelayInformation,
typeOfSrc: Type?,
context: JsonSerializationContext?
): JsonElement {
return JsonObject().apply {
addProperty("name", src.name)
addProperty("description", src.description)
addProperty("pubkey", src.pubkey)
addProperty("contact", src.contact)
add(
"supported_nip_extensions",
JsonArray().also { supported_nip_extensions ->
src.supported_nip_extensions?.forEach { nip ->
supported_nip_extensions.add(
nip
)
}
}
)
add(
"supported_nips",
JsonArray().also { supported_nips ->
src.supported_nips?.forEach { nip ->
supported_nips.add(
nip
)
}
}
)
addProperty("software", src.software)
addProperty("version", src.version)
add(
"relay_countries",
JsonArray().also { relay_countries ->
src.relay_countries?.forEach { country ->
relay_countries.add(
country
)
}
}
)
add(
"language_tags",
JsonArray().also { language_tags ->
src.language_tags?.forEach { language_tag ->
language_tags.add(
language_tag
)
}
}
)
add(
"tags",
JsonArray().also { tags ->
src.tags?.forEach { tag ->
tags.add(
tag
)
}
}
)
addProperty("posting_policy", src.posting_policy)
addProperty("payments_url", src.payments_url)
}
}
}
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.relays.FeedType
@Immutable
data class RelaySetupInfo(
val url: String,
val read: Boolean,
@@ -10,5 +12,6 @@ data class RelaySetupInfo(
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
val feedTypes: Set<FeedType>,
val paidRelay: Boolean = false
)
@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
@Stable
class Settings(
var automaticallyShowImages: Boolean? = null,
var automaticallyStartPlayback: Boolean? = null,
var preferredLanguage: String? = null,
var automaticallyShowUrlPreview: Boolean? = null
)
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -9,6 +11,8 @@ class ThreadAssembler {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
if (note.event is RepostEvent || note.event is GenericRepostEvent) return note
testedNotes.add(note)
val markedAsRoot = note.event?.tags()?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
@@ -35,17 +39,10 @@ class ThreadAssembler {
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
if (aTag != null) {
LocalCache.getOrCreateAddressableNote(aTag)
} else {
return emptySet()
}
} else {
LocalCache.getOrCreateNote(noteId)
}
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
object TimeUtils {
const val fiveMinutes = 60 * 5
const val oneHour = 60 * 60
const val oneDay = 24 * 60 * 60
fun now() = System.currentTimeMillis() / 1000
fun fiveMinutesAgo() = now() - fiveMinutes
fun oneHourAgo() = now() - oneHour
fun oneDayAgo() = now() - oneDay
fun eightHoursAgo() = now() - (oneHour * 8)
}
@@ -1,46 +1,58 @@
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
@Stable
object UrlCachedPreviewer {
var cache = mapOf<String, UrlInfoItem>()
private set
var failures = mapOf<String, Throwable>()
var cache = LruCache<String, UrlPreviewState>(100)
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
fun previewInfo(url: String, onReady: (UrlPreviewState) -> Unit) {
checkNotInMainThread()
cache[url]?.let {
callback?.onComplete(it)
onReady(it)
return
}
failures[url]?.let {
callback?.onFailed(it)
return
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return
}
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
val state = if (urlInfo.allFetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
).fetchUrlPreview()
}
override fun onFailed(throwable: Throwable) {
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
}
).fetchUrlPreview()
}
}
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -11,6 +12,8 @@ import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
@@ -28,6 +31,7 @@ class User(val pubkeyHex: String) {
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
var latestAcceptedBadges: AddressableNote? = null
var notes = setOf<Note>()
private set
@@ -46,8 +50,6 @@ class User(val pubkeyHex: String) {
var privateChatrooms = mapOf<User, Chatroom>()
private set
var acceptedBadges: AddressableNote? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
@@ -95,10 +97,10 @@ class User(val pubkeyHex: String) {
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
LocalCache.users[it]?.liveSet?.followers?.invalidateData()
}
liveSet?.relays?.invalidateData()
@@ -143,8 +145,10 @@ class User(val pubkeyHex: String) {
}
fun updateAcceptedBadges(note: AddressableNote) {
acceptedBadges = note
liveSet?.badges?.invalidateData()
if (latestAcceptedBadges?.idHex != note.idHex) {
latestAcceptedBadges = note
liveSet?.badges?.invalidateData()
}
}
fun addZap(zapRequest: Note, zap: Note?) {
@@ -184,28 +188,34 @@ class User(val pubkeyHex: String) {
}
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroomSync(user: User): Chatroom {
checkNotInMainThread()
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
privateChatroom
}
}
@Synchronized
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: getOrCreatePrivateChatroomSync(user)
}
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
}
}
@@ -228,6 +238,7 @@ class User(val pubkeyHex: String) {
info = newUserInfo
info?.latestMetadata = latestMetadata
info?.updatedMetadataAt = latestMetadata.createdAt
info?.tags = latestMetadata.tags.toImmutableListOfLists()
if (newUserInfo.lud16.isNullOrBlank() && newUserInfo.lud06?.lowercase()?.startsWith("lnurl") == true) {
try {
@@ -249,15 +260,11 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
return latestContactList?.isTaggedHash(tag) ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
@@ -272,11 +279,17 @@ class User(val pubkeyHex: String) {
} ?: false
}
fun isFollowingCached(userHex: String): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return userHex in it
} ?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowerCount(): Int {
suspend fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
@@ -292,7 +305,7 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
suspend fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
}
@@ -331,6 +344,7 @@ class User(val pubkeyHex: String) {
class UserLiveSet(u: User) {
// UI Observers line up here.
val follows: UserLiveData = UserLiveData(u)
val followers: UserLiveData = UserLiveData(u)
val reports: UserLiveData = UserLiveData(u)
val messages: UserLiveData = UserLiveData(u)
val relays: UserLiveData = UserLiveData(u)
@@ -342,6 +356,7 @@ class UserLiveSet(u: User) {
fun isInUse(): Boolean {
return follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
@@ -353,13 +368,34 @@ class UserLiveSet(u: User) {
}
}
@Immutable
data class RelayInfo(
val url: String,
var lastEvent: Long,
var counter: Long
)
data class Chatroom(var roomMessages: Set<Note>)
class Chatroom() {
var roomMessages: Set<Note> = setOf()
@Synchronized
fun addMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
@Synchronized
fun removeMessageSync(msg: Note) {
checkNotInMainThread()
if (msg !in roomMessages) {
roomMessages = roomMessages + msg
}
}
}
@Stable
class UserMetadata {
@@ -387,27 +423,53 @@ class UserMetadata {
var updatedMetadataAt: Long = 0
var latestMetadata: MetadataEvent? = null
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? {
return display_name ?: displayName ?: name ?: username
}
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
.any { it.contains(prefix, true) }
}
fun lnAddress(): String? {
return (lud16?.trim() ?: lud06?.trim())?.ifBlank { null }
}
fun bestUsername(): String? {
return name?.ifBlank { null } ?: username?.ifBlank { null }
}
fun bestDisplayName(): String? {
return displayName?.ifBlank { null } ?: display_name?.ifBlank { null }
}
fun nip05(): String? {
return nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (picture.isNullOrBlank()) picture = null
return picture
}
}
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(UserState(user))
}
}
}
override fun onActive() {
@@ -49,6 +49,8 @@ object BlurHashDecoder {
* By default it is true.
*/
fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
checkNotInMainThread()
if (blurHash == null || blurHash.length < 6) {
return null
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
@@ -14,12 +15,15 @@ import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.math.roundToInt
@Stable
class BlurHashFetcher(
private val options: Options,
private val data: Uri
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val encodedHash = data.toString().removePrefix("bluehash:")
val hash = URLDecoder.decode(encodedHash, "utf-8")
@@ -0,0 +1,291 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlContent
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import java.util.regex.Pattern
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
fun parseText(content: String, tags: ImmutableListOfLists<String>): RichTextViewerState {
return if (richTextCache[content] != null) {
richTextCache[content]
} else {
val newUrls = RichTextParser().parseText(content, tags)
richTextCache.put(content, newUrls)
newUrls
}
}
}
// Group 1 = url, group 4 additional chars
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
class RichTextParser() {
fun parseText(
content: String,
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapNotNullTo(LinkedHashSet(urls.size)) {
// removes e-mails
if (Patterns.EMAIL_ADDRESS.matcher(it.originalUrl).matches()) {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
return RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap(),
segments
)
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
if (wordSegment !is RegularTextSegment) {
isDirty = true
}
segments = segments.add(wordSegment)
}
val newSegments = if (isDirty) {
ParagraphState(segments, isRTL)
} else {
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
}
return paragraphSegments
}
fun isNumber(word: String): Boolean {
return numberPattern.matcher(word).matches()
}
fun isDate(word: String): Boolean {
return shortDatePattern.matcher(word).matches() || longDatePattern.matcher(word).matches()
}
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
private fun wordIdentifier(word: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): Segment {
val emailMatcher = Patterns.EMAIL_ADDRESS.matcher(word)
val phoneMatcher = Patterns.PHONE.matcher(word)
val schemelessMatcher = noProtocolUrlValidator.matcher(word)
return if (word.isEmpty()) {
RegularTextSegment(word)
} else if (images.contains(word)) {
ImageSegment(word)
} else if (urls.contains(word)) {
LinkSegment(word)
} else if (emojis.any { word.contains(it.key) }) {
EmojiSegment(word)
} else if (word.startsWith("lnbc", true)) {
InvoiceSegment(word)
} else if (word.startsWith("lnurl", true)) {
WithdrawSegment(word)
} else if (word.startsWith("cashuA", true)) {
CashuSegment(word)
} else if (emailMatcher.matches()) {
EmailSegment(word)
} else if (word.length in 7..14 && !isDate(word) && phoneMatcher.matches()) {
PhoneSegment(word)
} else if (startsWithNIP19Scheme(word)) {
BechSegment(word)
} else if (word.startsWith("#")) {
parseHash(word, tags)
} else if (schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(word)) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
}
} else {
RegularTextSegment(word)
}
}
private fun parseHash(word: String, tags: ImmutableListOfLists<String>): Segment {
// First #[n]
val matcher = tagIndex.matcher(word)
try {
if (matcher.find()) {
val index = matcher.group(1)?.toInt()
val suffix = matcher.group(2)
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
return HashIndexUserSegment(word, tag[1], suffix)
} else if (tag[0] == "e" || tag[0] == "a") {
return HashIndexEventSegment(word, tag[1], suffix)
}
}
}
}
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
val hashtagMatcher = hashTagsPattern.matcher(word)
try {
if (hashtagMatcher.find()) {
val hashtag = hashtagMatcher.group(1)
if (hashtag != null) {
return HashTagSegment(word, hashtag, hashtagMatcher.group(2))
}
}
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
}
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@Immutable
open class Segment(val segmentText: String)
@Immutable
class ImageSegment(segment: String) : Segment(segment)
@Immutable
class LinkSegment(segment: String) : Segment(segment)
@Immutable
class EmojiSegment(segment: String) : Segment(segment)
@Immutable
class InvoiceSegment(segment: String) : Segment(segment)
@Immutable
class WithdrawSegment(segment: String) : Segment(segment)
@Immutable
class CashuSegment(segment: String) : Segment(segment)
@Immutable
class EmailSegment(segment: String) : Segment(segment)
@Immutable
class PhoneSegment(segment: String) : Segment(segment)
@Immutable
class BechSegment(segment: String) : Segment(segment)
@Immutable
open class HashIndexSegment(segment: String, val hex: String, val extras: String?) : Segment(segment)
@Immutable
class HashIndexUserSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashIndexEventSegment(segment: String, hex: String, extras: String?) : HashIndexSegment(segment, hex, extras)
@Immutable
class HashTagSegment(segment: String, val hashtag: String, val extras: String?) : Segment(segment)
@Immutable
class SchemelessUrlSegment(segment: String, val url: String, val extras: String?) : Segment(segment)
@Immutable
class RegularTextSegment(segment: String) : Segment(segment)
@@ -0,0 +1,98 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Base64
@Immutable
data class CashuToken(
val token: String,
val mint: String,
val totalAmount: Long,
val fees: Int,
val redeemInvoiceAmount: Long,
val proofs: JsonArray
)
class CashuProcessor {
fun parse(cashuToken: String): GenericLoadable<CashuToken> {
checkNotInMainThread()
try {
val base64token = cashuToken.replace("cashuA", "")
val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token)))
val token = cashu.asJsonObject.get("token").asJsonArray[0].asJsonObject
val proofs = token["proofs"].asJsonArray
val mint = token["mint"].asString
var totalAmount = 0L
for (proof in proofs) {
totalAmount += proof.asJsonObject["amount"].asLong
}
val fees = Math.max(((totalAmount * 0.02).toInt()), 2)
val redeemInvoiceAmount = totalAmount - fees
return GenericLoadable.Loaded(CashuToken(cashuToken, mint, totalAmount, fees, redeemInvoiceAmount, proofs))
} catch (e: Exception) {
return GenericLoadable.Error<CashuToken>("Could not parse this cashu token")
}
}
fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
runCatching {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},
onProgress = {
},
onError = onError
)
}
}
private fun meltInvoice(token: CashuToken, invoice: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
try {
val client = HttpClient.getHttpClient()
val url = token.mint + "/melt" // Melt cashu tokens at Mint
val jsonObject = JsonObject()
jsonObject.add("proofs", token.proofs)
jsonObject.addProperty("pr", invoice)
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = jsonObject.toString().toRequestBody(mediaType)
val request = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = client.newCall(request).execute()
val body = response.body.string()
val tree = jacksonObjectMapper().readTree(body)
val successful = tree?.get("paid")?.asText() == "true"
if (successful) {
onSuccess("Redeemed ${token.totalAmount} Sats" + " (Fees: ${token.fees} Sats)")
} else {
onError(tree?.get("detail")?.asText()?.split('.')?.getOrNull(0) ?: "Cashu: Tokens already spent.")
}
} catch (e: Exception) {
onError("Token melt failure: " + e.message)
}
}
}
@@ -16,15 +16,16 @@ class FileHeader(
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
val description: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
} else {
onError()
}
@@ -39,6 +40,7 @@ class FileHeader(
fileUrl: String,
mimeType: String?,
description: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
@@ -79,7 +81,7 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
object HexValidator {
private fun isHex2(c: Char): Boolean {
return when (c) {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F', ' ' -> true
else -> false
}
}
fun isHex(hex: String?): Boolean {
if (hex == null) return false
var isHex = true
for (c in hex.toCharArray()) {
if (!isHex2(c)) {
isHex = false
break
}
}
return isHex
}
}
@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service
import android.os.Looper
import com.vitorpamplona.amethyst.BuildConfig
fun checkNotInMainThread() {
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
}
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
class OnMainThreadException(str: String) : RuntimeException(str)
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.runtime.Immutable
import java.util.regex.Pattern
@Immutable
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
@@ -18,7 +20,7 @@ class NIP30Parser {
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
val finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
@@ -13,14 +13,15 @@ import okhttp3.Request
import okhttp3.Response
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
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]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
@@ -33,6 +34,8 @@ class Nip05Verifier() {
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
@@ -47,8 +50,10 @@ class Nip05Verifier() {
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
HttpClient.getHttpClient().newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
checkNotInMainThread()
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
@@ -70,11 +75,16 @@ class Nip05Verifier() {
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
nip05,
onSuccess = {
checkNotInMainThread()
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
@@ -85,7 +95,12 @@ class Nip05Verifier() {
null
}
val user = nip05.split("@")[0].lowercase()
val parts = nip05.split("@")
val user = if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
val hexKey = nip05url?.get("names")?.get(user)?.asText()
@@ -51,9 +51,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 10
)
)
}
@@ -80,6 +80,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createAccountLastPostsListFilter(): TypedFilter {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = listOf(account.userProfile().pubkeyHex),
limit = 400
)
)
}
fun createNotificationFilter() = TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
@@ -88,6 +98,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
PollNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapPaymentResponseEvent.kind,
@@ -112,7 +123,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createNotificationFilter(),
createAccountReportsFilter(),
createAccountAcceptedAwardsFilter(),
createAccountBookmarkListFilter()
createAccountBookmarkListFilter(),
createAccountLastPostsListFilter()
).ifEmpty { null }
}
@@ -2,8 +2,10 @@ 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.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -12,9 +14,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
var account: Account? = null
var channel: Channel? = null
fun loadMessagesBetween(account: Account, channelId: String) {
fun loadMessagesBetween(account: Account, channel: Channel) {
this.account = account
channel = LocalCache.getOrCreateChannel(channelId)
this.channel = channel
resetFilters()
}
@@ -26,7 +28,7 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
fun createMessagesByMeToChannelFilter(): TypedFilter? {
val myAccount = account ?: return null
if (channel != null) {
if (channel is PublicChatChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
@@ -38,12 +40,24 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 50
)
)
} else if (channel is LiveActivitiesChannel) {
// Brings on messages by the user from all other relays.
// Since we ship with write to public, read from private only
// this guarantees that messages from the author do not disappear.
return TypedFilter(
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
authors = listOf(myAccount.userProfile().pubkeyHex),
limit = 50
)
)
}
return null
}
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
if (channel is PublicChatChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
@@ -52,6 +66,15 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
limit = 200
)
)
} else if (channel is LiveActivitiesChannel) {
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind),
tags = mapOf("a" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
}
@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
lateinit var account: Account
var withUser: User? = null
fun loadMessagesBetween(accountIn: Account, userId: String) {
fun loadMessagesBetween(accountIn: Account, user: User) {
account = accountIn
withUser = LocalCache.users[userId]
withUser = user
resetFilters()
}
@@ -74,7 +74,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
limit = 50 // Remember to consider spam that is being removed from the UI
)
)
}
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? {
val myCommunityToWatch = communityToWatch ?: return null
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags = mapOf(
"a" to listOf(myCommunityToWatch.address.toTag())
),
kinds = listOf(CommunityPostApprovalEvent.kind),
limit = 500
)
)
}
val loadCommunityChannel = requestNewChannel()
override fun updateChannelFilters() {
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
}
fun loadCommunity(note: AddressableNote?) {
communityToWatch = note
invalidateFilters()
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Client
@@ -12,7 +13,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
import kotlin.Error
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
LocalCache.consume(event, relay)
LocalCache.verifyAndConsume(event, relay)
}
}
@@ -57,7 +57,7 @@ abstract class NostrDataSource(val debugName: String) {
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
}
}
@@ -70,6 +70,7 @@ abstract class NostrDataSource(val debugName: String) {
}
init {
Log.d("Init", "${this.javaClass.simpleName} Subscribe")
Client.subscribe(clientListener)
}
@@ -103,16 +104,16 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate()
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
resetFiltersSuspend()
}
}
fun resetFilters() {
@@ -123,6 +124,8 @@ abstract class NostrDataSource(val debugName: String) {
}
fun resetFiltersSuspend() {
checkNotInMainThread()
// saves the channels that are currently active
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
@@ -0,0 +1,148 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val dataSource = "Discovery"
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter()
).ifEmpty { null }
}
}
@@ -1,28 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}
@@ -3,10 +3,15 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -47,18 +52,30 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
val followKeys = follows.map {
val followKeys = follows?.map {
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 6))
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(
TextNoteEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ClassifiedsEvent.kind,
LongTextNoteEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
LiveActivitiesChatMessageEvent.kind,
LiveActivitiesEvent.kind
),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -74,7 +91,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
@@ -48,14 +48,14 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 20
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
search = mySearchString,
limit = 20
limit = 100
)
)
)
@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -30,8 +32,8 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -49,13 +51,43 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
)
}
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
if (interestedEvents.isEmpty()) {
return null
}
// downloads linked events to this event.
return directEventsToLoad.map {
it.address().let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
)
)
}
}
}
val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter()
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
singleChannelChannel.typedFilters = (
(listOfNotNull(reactions, missing)) + (missingStreaming ?: emptyList())
).ifEmpty { null }
}
fun add(eventId: String) {
@@ -26,13 +26,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
ReactionEvent.kind, RepostEvent.kind, GenericRepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -48,14 +50,24 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex)
)
)
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
)
)
}
}
}
}
@@ -76,6 +88,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LongTextNoteEvent.kind,
ReactionEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
@@ -85,7 +98,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -113,16 +127,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
ids = interestedEvents.toList()
)
)
@@ -130,6 +134,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
checkNotInMainThread()
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
@@ -167,16 +173,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
fun addAddress(aTag: Note) {
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
fun addAddress(addressableNote: Note) {
if (!addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
fun removeAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote)
invalidateFilters()
}
}
@@ -28,7 +28,16 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(
TextNoteEvent.kind,
GenericRepostEvent.kind,
RepostEvent.kind,
LongTextNoteEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -81,9 +90,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
limit = 100
)
)
}
@@ -0,0 +1,40 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.Request
@Immutable
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5
fun isOnline(url: String?): Boolean {
checkNotInMainThread()
if (url.isNullOrBlank()) return false
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
return checkOnlineCache.get(url).online
}
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result
} catch (e: Exception) {
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), false))
Log.e("LiveActivities", "Failed to check streaming url $url", e)
false
}
}
}
@@ -0,0 +1,108 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.firstFullCharOld(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
else -> {
val first = isUTF16Char(0)
val second = isUTF16Char(2)
if (first && second) {
this.take(4)
} else if (first) {
this.take(2)
} else {
this.take(1)
}
}
}
}
fun String.firstFullChar(): String {
var isInJoin = false
var hasHadSecondChance = false
var start = 0
var previousCharLength = 0
var next: Int
var codePoint: Int
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else {
hasHadSecondChance = false
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
}
i = next
}
// if ends in join, then seachers backwards until a char is found.
if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
}
}
return substring(start, start + previousCharLength)
}
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
if (length <= 2) {
return firstFullChar()
}
if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
}
}
return firstFullChar()
}
@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.service.connectivitystatus
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
object ConnectivityStatus {
private val onMobileData = mutableStateOf(false)
val isOnMobileData: MutableState<Boolean> = onMobileData
private val onWifi = mutableStateOf(false)
val isOnWifi: MutableState<Boolean> = onWifi
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
onMobileData.value = isOnMobileData
onWifi.value = isOnWifi
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -45,6 +46,8 @@ class LightningAddressResolver() {
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
@@ -52,28 +55,32 @@ class LightningAddressResolver() {
return
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
try {
withContext(Dispatchers.IO) {
val request: Request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful) {
onSuccess(it.body.string())
} else {
onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct")
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()
}
})
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
e.printStackTrace()
}
})
}
} catch (e: Exception) {
onError("Could not resolve $url. Check if the server up and if the lightning address $lnaddress is correct")
}
}
@@ -169,7 +176,7 @@ class LightningAddressResolver() {
null
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -98,11 +99,11 @@ object LnInvoiceUtil {
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
private fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
throw IllegalArgumentException("Cannot decode invoice", e)
throw IllegalArgumentException("Cannot decode invoice: $invoice", e)
}
val matcher = invoicePattern.matcher(invoice)
@@ -121,6 +122,8 @@ object LnInvoiceUtil {
}
fun getAmountInSats(invoice: String): BigDecimal {
checkNotInMainThread()
return getAmount(invoice).multiply(BigDecimal(100000000))
}
@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.UserMetadata
import java.io.ByteArrayInputStream
@Immutable
class AppDefinitionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun appMetaData() = try {
MetadataEvent.metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
runCatching { it[1].toInt() }.getOrNull()
}
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
companion object {
const val kind = 31990
}
}
@@ -0,0 +1,27 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class AppRecommendationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
ATag.parse(it[1], it.getOrNull(2))
}
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 31989
}
}
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class AudioTrackEvent(
@@ -42,7 +42,7 @@ class AudioTrackEvent(
cover: String? = null,
subject: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): AudioTrackEvent {
val tags = listOfNotNull(
listOf(MEDIA, media),
@@ -18,7 +18,7 @@ open class BaseTextNoteEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
open fun replyTos() = taggedEvents()
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
@@ -113,7 +113,7 @@ open class BaseTextNoteEvent(
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class BookmarkListEvent(
@@ -30,7 +30,7 @@ class BookmarkListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelCreateEvent(
@@ -26,7 +26,7 @@ class ChannelCreateEvent(
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
val content = try {
if (channelInfo != null) {
gson.toJson(channelInfo)
@@ -46,5 +46,6 @@ class ChannelCreateEvent(
}
}
data class ChannelData(var name: String?, var about: String?, var picture: String?)
@Immutable
data class ChannelData(val name: String?, val about: String?, val picture: String?)
}
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelHideMessageEvent(
@@ -14,13 +14,19 @@ class ChannelHideMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(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>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelHideMessageEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMessageEvent(
@@ -14,9 +14,14 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
companion object {
@@ -29,8 +34,9 @@ class ChannelMessageEvent(
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
@@ -49,6 +55,9 @@ class ChannelMessageEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
@@ -56,3 +65,7 @@ class ChannelMessageEvent(
}
}
}
interface IsInPublicChatChannel {
open fun channel(): String?
}
@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMetadataEvent(
@@ -15,8 +15,9 @@ class ChannelMetadataEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun channelInfo() =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
@@ -28,7 +29,7 @@ class ChannelMetadataEvent(
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMetadataEvent {
val content =
if (newChannelInfo != null) {
gson.toJson(newChannelInfo)
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMuteUserEvent(
@@ -14,14 +14,19 @@ class ChannelMuteUserEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(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 {
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class ClassifiedsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
Price(it[1], it.getOrNull(2), it.getOrNull(3))
}
fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1)
fun publishedAt() = try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
null
}
companion object {
const val kind = 30402
fun create(
dTag: String,
title: String?,
image: String?,
summary: String?,
price: Price?,
location: String?,
publishedAt: Long?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ClassifiedsEvent {
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", dTag))
title?.let { tags.add(listOf("title", it)) }
image?.let { tags.add(listOf("image", it)) }
summary?.let { tags.add(listOf("summary", it)) }
price?.let {
if (it.frequency != null && it.currency != null) {
tags.add(listOf("price", it.amount, it.currency, it.frequency))
} else if (it.currency != null) {
tags.add(listOf("price", it.amount, it.currency))
} else {
tags.add(listOf("price", it.amount))
}
}
location?.let { tags.add(listOf("location", it)) }
publishedAt?.let { tags.add(listOf("publishedAt", it.toString())) }
title?.let { tags.add(listOf("title", it)) }
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return ClassifiedsEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Price(val amount: String, val currency: String?, val frequency: String?)
@@ -0,0 +1,42 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class CommunityDefinitionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1)
fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
companion object {
const val kind = 34550
fun create(
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): CommunityDefinitionEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return CommunityDefinitionEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
@Immutable
class CommunityPostApprovalEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun containedPost(): Event? = try {
content.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.w("CommunityPostEvent", "Failed to Parse Community Approval Contained Post of $id with $content")
null
}
fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTag = ATag.parse(it[1], it.getOrNull(2))
if (aTag?.kind == CommunityDefinitionEvent.kind) {
aTag
} else {
null
}
}
fun approvedEvents() = tags.filter {
it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind))
}.map {
it[1]
}
companion object {
const val kind = 4550
fun create(approvedPost: Event, community: CommunityDefinitionEvent, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
val content = approvedPost.toJson()
val communities = listOf("a", community.address().toTag())
val replyToPost = listOf("e", approvedPost.id())
val replyToAuthor = listOf("p", approvedPost.pubKey())
val kind = listOf("k", "${approvedPost.kind()}")
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags: List<List<String>> = listOf(communities, replyToPost, replyToAuthor, kind)
val id = generateId(pubKey, createdAt, GenericRepostEvent.kind, tags, content)
val sig = Utils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -2,16 +2,18 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
@Stable
class ContactListEvent(
id: HexKey,
pubKey: HexKey,
@@ -23,14 +25,12 @@ class ContactListEvent(
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
try {
decodePublicKey(it[1]).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}.toSet()
}
@@ -73,7 +73,7 @@ class ContactListEvent(
companion object {
const val kind = 3
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class DeletionEvent(
@@ -20,7 +20,7 @@ class DeletionEvent(
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = deleteEvents.map { listOf("e", it) }
@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30030
fun create(
name: String = "",
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return EmojiPackEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@Immutable
data class EmojiUrl(val code: String, val url: String) {
fun encode(): String {
return ":$code:$url"
}
companion object {
fun decode(encodedEmojiSetup: String): EmojiUrl? {
val emojiParts = encodedEmojiSetup.split(":", limit = 3)
return if (emojiParts.size > 2) {
EmojiUrl(emojiParts[1], emojiParts[2])
} else {
null
}
}
}
}
@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackSelectionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 10030
fun create(
listOfEmojiPacks: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackSelectionEvent {
val msg = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
listOfEmojiPacks?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
@@ -48,12 +49,18 @@ open class Event(
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nude", true))
}
override fun zapraiserAmount() = tags.firstOrNull() {
(it.size > 1 && it[0] == "zapraiser")
}?.get(1)?.toLongOrNull()
override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
@@ -65,12 +72,33 @@ open class Event(
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
override fun isTaggedAddressableKind(kind: Int): Boolean {
val kindStr = kind.toString()
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
}
override fun getTagOfAddressableKind(kind: Int): ATag? {
val kindStr = kind.toString()
val aTag = tags
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
?.getOrNull(1)
?: return null
return ATag.parse(aTag, null)
}
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
@@ -138,7 +166,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
@@ -212,7 +240,6 @@ open class Event(
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
@@ -228,6 +255,8 @@ open class Event(
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
@@ -238,13 +267,21 @@ open class Event(
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.kind -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.kind -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.kind -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -257,6 +294,7 @@ open class Event(
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -281,10 +319,10 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey).toHexKey()
@@ -27,10 +27,16 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedAddressableNote(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun firstIsTaggedAddressableNote(addressableNotes: Set<String>): String?
fun isTaggedAddressableKind(kind: Int): Boolean
fun getTagOfAddressableKind(kind: Int): ATag?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
@@ -38,4 +44,8 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean
}
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileHeaderEvent(
@@ -50,8 +50,9 @@ class FileHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileHeaderEvent {
val tags = listOfNotNull(
listOf(URL, url),
@@ -62,7 +63,14 @@ class FileHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""
@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
@Immutable
class FileStorageEvent(
@@ -44,7 +44,7 @@ class FileStorageEvent(
mimeType: String,
data: ByteArray,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileStorageEvent {
val tags = listOfNotNull(
listOf(TYPE, mimeType)
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileStorageHeaderEvent(
@@ -50,8 +50,9 @@ class FileStorageHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
@@ -62,7 +63,14 @@ class FileStorageHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""
@@ -0,0 +1,52 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
@Immutable
class GenericRepostEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun containedPost() = try {
fromJson(content, Client.lenient)
} catch (e: Exception) {
null
}
companion object {
const val kind = 16
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}
tags = tags + listOf(listOf("k", "${boostedPost.kind()}"))
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,49 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.security.MessageDigest
@Immutable
class HTTPAuthorizationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 27235
fun create(
url: String,
method: String,
body: String? = null,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): HTTPAuthorizationEvent {
val sha256 = MessageDigest.getInstance("SHA-256")
var hash = ""
body?.let {
hash = sha256.digest(it.toByteArray()).toHexKey()
}
val tags = listOfNotNull(
listOf("u", url),
listOf("method", method),
listOf("payload", hash)
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return HTTPAuthorizationEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class HighlightEvent(
@@ -26,7 +26,7 @@ class HighlightEvent(
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -0,0 +1,82 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class LiveActivitiesChatMessageEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
private fun innerActivity() = tags.firstOrNull {
it.size > 3 && it[0] == "a" && it[3] == "root"
} ?: tags.firstOrNull {
it.size > 1 && it[0] == "a"
}
private fun activityHex() = innerActivity()?.let {
it.getOrNull(1)
}
fun activity() = innerActivity()?.let {
if (it.size > 1) {
val aTagValue = it[1]
val relay = it.getOrNull(2)
ATag.parse(aTagValue, relay)
} else {
null
}
}
override fun replyTos() = taggedEvents().minus(activityHex() ?: "")
companion object {
const val kind = 1311
fun create(
message: String,
activity: ATag,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
listOf("a", activity.toTag(), "", "root")
)
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return LiveActivitiesChatMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -0,0 +1,60 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class LiveActivitiesEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull()
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
fun checkStatus(eventStatus: String?): String? {
return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) {
STATUS_ENDED
} else {
eventStatus
}
}
companion object {
const val kind = 30311
const val STATUS_LIVE = "live"
const val STATUS_PLANNED = "planned"
const val STATUS_ENDED = "ended"
fun create(
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): LiveActivitiesEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return LiveActivitiesEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -7,11 +7,11 @@ import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
@Immutable
class LnZapPaymentRequestEvent(
@@ -57,7 +57,7 @@ class LnZapPaymentRequestEvent(
lnInvoice: String,
walletServicePubkey: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.nio.charset.Charset
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
@@ -63,7 +64,7 @@ class LnZapRequestEvent(
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey
@@ -103,7 +104,7 @@ class LnZapRequestEvent(
privateKey: ByteArray,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey
@@ -133,7 +134,7 @@ class LnZapRequestEvent(
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return sha256.digest(strbyte)
return MessageDigest.getInstance("SHA-256").digest(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LongTextNoteEvent(
@@ -24,7 +24,7 @@ class LongTextNoteEvent(
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()
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
null
}
@@ -32,7 +32,7 @@ class LongTextNoteEvent(
companion object {
const val kind = 30023
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
@@ -1,25 +1,22 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ContactMetaData(
val name: String,
val picture: String,
val about: String,
val nip05: String?
)
import java.io.ByteArrayInputStream
@Stable
abstract class IdentityClaim(
var identity: String,
var proof: String
val identity: String,
val proof: String
) {
abstract fun toProofUrl(): String
abstract fun toIcon(): Int
@@ -145,9 +142,13 @@ class MetadataEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun contactMetaData() = try {
gson.fromJson(content, ContactMetaData::class.java)
metadataParser.readValue(
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
UserMetadata::class.java
)
} catch (e: Exception) {
Log.e("MetadataEvent", "Can't parse $content", e)
e.printStackTrace()
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
null
}
@@ -164,11 +165,13 @@ class MetadataEvent(
const val kind = 0
val gson = Gson()
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
val metadataParser by lazy {
jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): MetadataEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -4,10 +4,10 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
@@ -71,7 +71,7 @@ class MuteListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): MuteListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PeopleListEvent(
@@ -30,7 +30,7 @@ class PeopleListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PinListEvent(
@@ -27,7 +27,7 @@ class PinListEvent(
fun create(
pins: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PinListEvent {
val tags = mutableListOf<List<String>>()
pins.forEach {
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
const val POLL_OPTION = "poll_option"
const val VALUE_MAXIMUM = "value_maximum"
@@ -45,14 +45,15 @@ class PollNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
pollOptions: Map<Int, String>,
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -79,6 +80,9 @@ class PollNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -3,11 +3,12 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.HexValidator
import fr.acinq.secp256k1.Hex
import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
@Immutable
class PrivateDmEvent(
@@ -23,11 +24,18 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun verifiedRecipientPubKey(): HexKey? {
val recipient = recipientPubKey()
return if (HexValidator.isHex(recipient)) {
recipient
} else {
null
}
}
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
@@ -75,10 +83,11 @@ class PrivateDmEvent(
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true,
markAsSensitive: Boolean
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -102,6 +111,9 @@ class PrivateDmEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$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())
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ReactionEvent(
@@ -22,15 +22,15 @@ class ReactionEvent(
companion object {
const val kind = 7
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
@@ -42,5 +42,24 @@ class ReactionEvent(
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(emojiUrl: EmojiUrl, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
val content = ":${emojiUrl.code}:"
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("emoji", emojiUrl.code, emojiUrl.url)
)
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}
@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.net.URI
import java.util.Date
@Immutable
class RecommendRelayEvent(
@@ -27,7 +27,7 @@ class RecommendRelayEvent(
companion object {
const val kind = 2
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RecommendRelayEvent {
val content = relay.toString()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelayAuthEvent(
@@ -21,7 +21,7 @@ class RelayAuthEvent(
companion object {
const val kind = 22242
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
@@ -0,0 +1,44 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class RelaySetEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
fun description() = tags.firstOrNull() { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30022
fun create(
relays: List<String>,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): RelaySetEvent {
val tags = mutableListOf<List<String>>()
relays.forEach {
tags.add(listOf("r", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return RelaySetEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
@@ -58,7 +58,7 @@ class ReportEvent(
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
@@ -75,7 +75,7 @@ class ReportEvent(
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReportEvent {
val content = ""
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())
@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class RepostEvent(
@@ -17,8 +17,8 @@ class RepostEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun boostedPost() = taggedEvents()
fun originalAuthor() = taggedUsers()
fun containedPost() = try {
fromJson(content, Client.lenient)
@@ -29,14 +29,14 @@ class RepostEvent(
companion object {
const val kind = 6
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())
val replyToAuthor = listOf("p", boostedPost.pubKey())
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
var tags: List<List<String>> = listOf(replyToPost, replyToAuthor)
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
@@ -4,10 +4,10 @@ import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@Immutable
class TextNoteEvent(
@@ -19,6 +19,10 @@ class TextNoteEvent(
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun subject() = tags.firstOrNull() { it.size > 1 && it[0] == "subject" }?.get(1)
fun root() = tags.firstOrNull() { it.size > 3 && it[3] == "root" }?.get(1)
companion object {
const val kind = 1
@@ -30,19 +34,45 @@ class TextNoteEvent(
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
zapRaiserAmount: Long?,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {
tags.add(listOf("e", it))
if (it == replyingTo) {
tags.add(listOf("e", it, "", "reply"))
} else if (it == root) {
tags.add(listOf("e", it, "", "root"))
} else if (it in directMentions) {
tags.add(listOf("e", it, "", "mention"))
} else {
tags.add(listOf("e", it))
}
}
mentions?.forEach {
tags.add(listOf("p", it))
if (it in directMentions) {
tags.add(listOf("p", it, "", "mention"))
} else {
tags.add(listOf("p", it))
}
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
val aTag = it.toTag()
if (aTag == replyingTo) {
tags.add(listOf("a", aTag, "", "reply"))
} else if (aTag == root) {
tags.add(listOf("a", aTag, "", "root"))
} else if (aTag in directMentions) {
tags.add(listOf("a", aTag, "", "mention"))
} else {
tags.add(listOf("a", aTag))
}
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
@@ -60,6 +90,9 @@ class TextNoteEvent(
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
zapRaiserAmount?.let {
tags.add(listOf("zapraiser", "$it"))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
@@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
object UserZaps {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
if (zaps == null) return emptyList()
return (
zaps
.filter { it.value != null }
.toList()
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
.mapNotNull { entry ->
entry.value?.let {
ZapReqResponse(entry.key, it)
}
}
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
.reversed()
) as List<Pair<Note, Note>>
)
}
}

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