Compare commits

...

430 Commits

Author SHA1 Message Date
Vitor Pamplona
093705f3b6 v0.52.3 2023-05-29 18:06:42 -04:00
Vitor Pamplona
1972297755 adds flavor to the version descriptor in the bottom left of the drawer 2023-05-29 18:06:35 -04:00
Vitor Pamplona
ac00694690 Moves ExoPlayer to OkHttp in order to enable Tor support. 2023-05-29 18:00:29 -04:00
Vitor Pamplona
605861dc32 v0.53.2 2023-05-29 17:12:04 -04:00
Vitor Pamplona
43d508ba33 Correcting the user's room for the notification marker 2023-05-29 17:11:56 -04:00
Vitor Pamplona
e692647d58 v0.53.1 2023-05-29 16:26:16 -04:00
Vitor Pamplona
64f98e4b92 Filters Message Feed DAL to only take PrivateMessages in the Notification Bubble. 2023-05-29 16:25:35 -04:00
Vitor Pamplona
c58c20cc54 Adding immutable tag to ResizeImage class 2023-05-29 15:15:20 -04:00
Vitor Pamplona
89ddb7f4b7 v0.53.0 2023-05-29 15:05:01 -04:00
Vitor Pamplona
cb92a51191 Adds separate colors for the newItem background between light and dark themes 2023-05-29 14:58:10 -04:00
Vitor Pamplona
213b172c32 Merge pull request #431 from davotoula/add_translation_cs
Add Czech translations
2023-05-29 14:27:01 -04:00
Vitor Pamplona
cfdd470626 formatting 2023-05-29 14:25:43 -04:00
Vitor Pamplona
a5a3c62edd Moving chatroomlist to an additive filter 2023-05-29 14:25:34 -04:00
David Kaspar
c9bcfa1c17 added Czech translation 2023-05-29 18:36:51 +01:00
Vitor Pamplona
02ad85a740 Avoids running through the entire filter when collection is Empty 2023-05-28 17:53:08 -04:00
Vitor Pamplona
446273de80 Adds mute control per track on the screen. 2023-05-28 17:36:20 -04:00
Vitor Pamplona
b513877552 Optimizing UserProfile status 2023-05-28 17:33:01 -04:00
Vitor Pamplona
013eedd892 Removing logs 2023-05-28 17:32:48 -04:00
Vitor Pamplona
d2cbb27756 Separating logs for full feed updates and additive feed updates 2023-05-28 17:32:40 -04:00
Vitor Pamplona
1a477ea1c8 Refreshing user status when added to the transient spam list 2023-05-28 17:32:05 -04:00
Vitor Pamplona
83df639aff Merge pull request #430 from malimahda/main
changes to translation
2023-05-28 16:25:10 -04:00
Vitor Pamplona
f298cc6f13 Adds some documentation to make sure any NIP05 changes take into account that we are converting the entire json to lowercase. 2023-05-28 16:24:37 -04:00
Vitor Pamplona
300a70ce18 Merge remote-tracking branch 'origin/HEAD' 2023-05-28 16:09:54 -04:00
Vitor Pamplona
0f6bf1cc59 Merge pull request #428 from davotoula/bugfix_case_sensitive_nip05
Bugfix case sensitive nip05
2023-05-28 16:09:37 -04:00
Vitor Pamplona
6bc1b6b28a Loading reply from launch and not withContent 2023-05-28 16:03:15 -04:00
Vitor Pamplona
1c381f64f9 v0.52.3 2023-05-28 15:35:46 -04:00
Vitor Pamplona
5e3703e849 Moves all note states to leaf compositors 2023-05-28 15:34:13 -04:00
Vitor Pamplona
acbd41ce61 Better recomposition structure for ReactionsRow 2023-05-28 15:17:10 -04:00
Vitor Pamplona
056d00b73b Performance Improvements when rendering a channel creation note 2023-05-28 15:16:50 -04:00
L
72fc21cdc6 Update strings.xml
translated new strings
2023-05-28 21:22:33 +03:30
L
3e983cdf1c Update strings.xml
added new strings
2023-05-28 20:40:16 +03:30
L
d81af9a194 Merge branch 'vitorpamplona:main' into main 2023-05-28 17:59:35 +03:30
David Kaspar
7f6b44cd0b spelling fix 2023-05-27 23:51:13 +01:00
David Kaspar
88bad1e308 added additional trivial tests 2023-05-27 22:35:34 +01:00
David Kaspar
aa0a4d1aac Use lowercase versions of user name and retrieved json to make case insensitive match 2023-05-27 21:56:57 +01:00
David Kaspar
b0a61cce42 Added failing test
(a bit dirty using a spy for the actual "system under test" but it is the quickest way to test added functionality and mocking out complexity around coroutines and httpclient)
2023-05-27 21:42:12 +01:00
Vitor Pamplona
b3be4567e2 v0.52.2 2023-05-27 16:36:36 -04:00
Vitor Pamplona
c5a586ec56 Fixes Missing Notifications 2023-05-27 16:36:27 -04:00
Vitor Pamplona
ece3664e74 Merge remote-tracking branch 'origin/HEAD' 2023-05-27 16:34:42 -04:00
Vitor Pamplona
2297c797dc Merge pull request #427 from ZsZolee/main
Sensitive content Hungarian translation
2023-05-27 16:24:48 -04:00
Vitor Pamplona
4517ca9fdd trimming urls as well 2023-05-27 16:16:52 -04:00
Vitor Pamplona
1146b8ad54 v0.52.1 2023-05-27 16:06:15 -04:00
Vitor Pamplona
545643d165 Fixes Relay Icon rendering. 2023-05-27 16:04:20 -04:00
Zoltan
359e22ae42 Sensitive content Hungarian translation 2023-05-27 17:02:15 +02:00
Vitor Pamplona
69be28ec98 Making sure the UserReactonsRow in notifications get updated as new events come in. 2023-05-26 18:05:35 -04:00
Vitor Pamplona
a8ffbb922d Moves relay color filters to a singleton 2023-05-26 14:03:57 -04:00
Vitor Pamplona
bd4a450336 v0.52.0 2023-05-26 12:21:10 -04:00
Vitor Pamplona
39818268ab Support for NIP-36, declaration of sensitive content. 2023-05-26 12:20:17 -04:00
Vitor Pamplona
d26b83a851 Updating kotlin dependencies. 2023-05-26 12:19:38 -04:00
Vitor Pamplona
8dc9c2ed2f 0.51.4 2023-05-26 09:37:51 -04:00
Vitor Pamplona
860f3bd096 Improves color contrast for Zap Amount notifications in light mode 2023-05-26 09:00:05 -04:00
Vitor Pamplona
a3adadb251 0.51.3 2023-05-25 21:09:47 -04:00
Vitor Pamplona
755d89dc79 Only changing stats for the Reaction Row if the data actually changed. 2023-05-25 21:09:14 -04:00
Vitor Pamplona
fa4463f1d7 Improves rendering of messages in the Notification feed 2023-05-25 21:08:20 -04:00
Vitor Pamplona
56a68b9100 Feed invalidation is necessary for lists 2023-05-25 20:40:04 -04:00
Vitor Pamplona
fe317964b8 Simplifying Private message rendering scheme 2023-05-25 20:39:15 -04:00
Vitor Pamplona
07ccf75d44 updates jsoup and activity compose to the latest versions 2023-05-25 18:43:01 -04:00
Vitor Pamplona
ff927d4830 v0.51.2 2023-05-25 18:38:34 -04:00
Vitor Pamplona
6461c3e498 Changes the structure of the notification view to increase speed. 2023-05-25 18:35:23 -04:00
Vitor Pamplona
2ed5eae029 Preserves invalidations of DataSources for Home, Videos and Notifications 2023-05-25 18:34:40 -04:00
Vitor Pamplona
1693455d0d Only removes scrollTop from Uri after 1 second and to avoid changing before composition finishes. 2023-05-25 18:33:22 -04:00
Vitor Pamplona
3bcda33fa6 Activates invalidations that can be ignored if another invalidation is already running. 2023-05-25 18:30:42 -04:00
Vitor Pamplona
9f394900a7 Only scrolls to the top if not at the top 2023-05-25 18:30:15 -04:00
Vitor Pamplona
4f2c78e73a Remembers formatting for the Notification Reaction Row 2023-05-25 18:29:41 -04:00
Vitor Pamplona
6a3600fd73 Updates Notification lists in intervals of 1 second. 2023-05-25 18:28:51 -04:00
Vitor Pamplona
b40c6f1361 remember modifiers to avoid recomposition 2023-05-25 18:28:34 -04:00
Vitor Pamplona
5ce19474ae Moves Home and Video screen invalidations to follow the scrollTop parameter 2023-05-25 18:28:19 -04:00
Vitor Pamplona
e9bdc045ba Adds @Stable tag to Notification Card models 2023-05-25 18:27:05 -04:00
Vitor Pamplona
aed7f46ca0 remembering size of the robohash picture 2023-05-25 18:25:47 -04:00
Vitor Pamplona
310a93ef29 removes logs 2023-05-25 16:24:16 -04:00
Vitor Pamplona
1ccaa1a028 Removing the start of relays before logging in. 2023-05-25 08:56:04 -04:00
Vitor Pamplona
493888d121 v0.51.1 2023-05-24 18:12:46 -04:00
Vitor Pamplona
e5aba62fdd Imports BahaUrlPreview library to add the proxy connection into it. 2023-05-24 18:11:51 -04:00
Vitor Pamplona
b643cedf8f Merge pull request #424 from KotlinGeekDev/retain-state
Retain state when rotating device.
2023-05-24 17:02:27 -04:00
Vitor Pamplona
2c0bfe2118 v0.51.0 2023-05-24 17:01:30 -04:00
Vitor Pamplona
f1ecaf1ee5 - Adds Immutable Lists to avoid recompositions
- Moves NavController to a lambda to avoid recompositions
- Moves AccountViewModel builder to a ViewModel Factory to avoid recompositions
- Reduces notifications invalidation requests when starting the screen.
2023-05-24 12:34:09 -04:00
Vitor Pamplona
4841d4004e Fixes https://github.com/vitorpamplona/amethyst/issues/425 2023-05-24 10:38:25 -04:00
KotlinGeek
75d56214d2 Use manifest to avoid Activity recreation. 2023-05-23 23:58:59 +01:00
Vitor Pamplona
f0ba68e467 Fixing missing some notifications. 2023-05-23 16:04:29 -04:00
Vitor Pamplona
5a375b94d6 Adds Zap amounts to Notification bubbles. 2023-05-23 15:14:00 -04:00
Vitor Pamplona
dcbd4148e2 v0.50.6 2023-05-23 12:14:47 -04:00
Vitor Pamplona
0015100dea Fixing Immutable imports 2023-05-23 12:12:43 -04:00
Vitor Pamplona
8898e5d10b Adds Immutable and Stable annotations to avoid recomposing 2023-05-23 12:02:34 -04:00
Vitor Pamplona
15621f16a2 Improves the speed of updating Notification notes 2023-05-23 10:37:34 -04:00
Vitor Pamplona
a29d9ad433 Adds a cache layer for citations in Notes 2023-05-23 10:37:18 -04:00
Vitor Pamplona
d355f22938 Fixes click on the Home to update the feed 2023-05-23 10:35:56 -04:00
Vitor Pamplona
2082c6cb28 Fixes size for following symbol 2023-05-22 21:01:25 -04:00
Vitor Pamplona
4b49cc1aab better translations to unfollow in portuguese 2023-05-22 20:47:34 -04:00
Vitor Pamplona
538e61b38e rollback vm version 2023-05-22 20:47:13 -04:00
Vitor Pamplona
93f8847544 moving plugin to the latest 2023-05-22 20:41:42 -04:00
Vitor Pamplona
a1da4affeb Updating dependencies 2023-05-22 20:04:33 -04:00
Vitor Pamplona
0bf94f316f Moving more variables to remember clauses 2023-05-22 20:04:23 -04:00
Vitor Pamplona
a3fc22a04a v0.50.5 2023-05-22 18:24:50 -04:00
Vitor Pamplona
6437a2106a Moves zapProgress to inside the IO update to make sure it only updates when needed. 2023-05-22 17:52:46 -04:00
Vitor Pamplona
0a8c79dacd Stops recomposing the drawer at every new route. 2023-05-22 17:52:18 -04:00
Vitor Pamplona
22dbaaf95a Adjusts bottombar to not recalculate hasNewItems in every recomposition. 2023-05-22 17:51:59 -04:00
Vitor Pamplona
278091e6ef Moves default state of url previews to the remember function 2023-05-22 17:51:37 -04:00
Vitor Pamplona
d99711d3e6 Remembering Background color and Text elements for posts. 2023-05-22 17:51:12 -04:00
Vitor Pamplona
48f8cd7d95 Merge remote-tracking branch 'origin/HEAD' 2023-05-22 15:11:10 -04:00
Vitor Pamplona
3b816559e4 Optimizes MultiSetCompose 2023-05-22 15:10:54 -04:00
Vitor Pamplona
e96a50b454 Optimizes recomposition of bottom items. 2023-05-22 15:08:14 -04:00
Vitor Pamplona
93282b71ae Optimizes NIP-05 procedures 2023-05-22 15:07:47 -04:00
Vitor Pamplona
8223476cb3 Formatting 2023-05-22 15:07:21 -04:00
Vitor Pamplona
460187c4a4 Optimizes Drawer for Recomposition 2023-05-22 15:07:13 -04:00
Vitor Pamplona
5da49c920b Removes the time log for Composing a note 2023-05-22 14:55:06 -04:00
Vitor Pamplona
df5b5b90af Formatting 2023-05-22 14:54:42 -04:00
Vitor Pamplona
e5b937ed7e Only updates the refreshing state if it was refreshing. 2023-05-22 14:54:25 -04:00
Vitor Pamplona
b4b90cfd19 Merge pull request #420 from KotlinGeekDev/back-navigation-fix
Improve navigation behaviour.
2023-05-22 14:35:50 -04:00
Vitor Pamplona
99d302802a Deprecating IMGUR 2023-05-22 09:50:06 -04:00
Vitor Pamplona
ec2fc2f7e3 Updates relay UI on messages sent by the logged in user in chat 2023-05-22 09:49:34 -04:00
Vitor Pamplona
26634ccb04 Migrates notification summary to a new day without having to restart the app. 2023-05-22 09:18:13 -04:00
KotlinGeek
7766886587 Apply fix to profile navigation as well. 2023-05-22 12:56:17 +01:00
Vitor Pamplona
b219b3631c - Adds hashtags and #[n] rendering to markdown
- Shortcuts the link to Amethyst's router.
2023-05-21 20:54:42 -04:00
Vitor Pamplona
a0797f7104 Fixes NWC with Auth 2023-05-21 20:53:57 -04:00
Vitor Pamplona
330f3503a6 Adds nostr: prefix to channel id share. 2023-05-21 20:53:20 -04:00
KotlinGeek
7da87af0fb Verify presence of note in backstack before navigating to it. 2023-05-22 01:29:38 +01:00
Vitor Pamplona
2eb0096630 Adds a space in a bookmark author's name 2023-05-20 20:35:13 -04:00
L
459e579bcb Update strings.xml
changed some word to better choices
2023-05-20 20:46:04 +03:30
L
3c069d574e Merge branch 'vitorpamplona:main' into main 2023-05-20 20:38:19 +03:30
Vitor Pamplona
639569cd86 v0.50.4 2023-05-17 17:05:06 -04:00
Vitor Pamplona
e41df98920 Bugfix for channel metadata messages show up before channel creation packages 2023-05-17 17:01:07 -04:00
Vitor Pamplona
323e71c7cb Bugfix for saving Replaceable notes in Bookmarks. 2023-05-17 15:47:20 -04:00
Vitor Pamplona
cbe9462f28 v0.50.3 2023-05-17 13:55:30 -04:00
Vitor Pamplona
95febc1c8e Merge remote-tracking branch 'origin/main' 2023-05-17 13:55:17 -04:00
Vitor Pamplona
5d8ffd1e2c Merge pull request #415 from KotlinGeekDev/hashtag-search-fix
Fix for case-sensitive hashtag issue.
2023-05-17 13:55:02 -04:00
Vitor Pamplona
f2616855c2 Merge pull request #414 from ZsZolee/main
Replacing some missing Hungarian translation
2023-05-17 13:54:50 -04:00
Vitor Pamplona
5040350be5 Fixes Auth for NIP-42 sporadic connections. 2023-05-17 13:54:20 -04:00
KotlinGeek
d6c8651f0d Add lowercase variant of hashtag when creating event. 2023-05-17 17:26:31 +01:00
Zoltan
c5176b8c49 Replacing some missing Hungarian translation 2023-05-17 17:27:43 +02:00
Vitor Pamplona
d239020895 v0.50.2 2023-05-17 11:20:14 -04:00
Vitor Pamplona
117446688b Fixes jumping chat screens on hidden posts. 2023-05-17 11:20:03 -04:00
Vitor Pamplona
a8e650a38d v0.50.1 2023-05-17 09:50:43 -04:00
Vitor Pamplona
95a21cc08c Adds Navigation markers to the top panel and reaction watch 2023-05-17 09:49:31 -04:00
Vitor Pamplona
0823f2e7b5 Additive rendering of statistics 2023-05-17 09:34:21 -04:00
Vitor Pamplona
cb42196889 Adds reaction watch. 2023-05-16 22:41:47 -04:00
Vitor Pamplona
6aadf8a883 0.49.4 2023-05-16 19:00:44 -04:00
Vitor Pamplona
bb9fa8e144 Adding the missing search element 2023-05-16 18:51:08 -04:00
Vitor Pamplona
5a3cf40402 Adds online search when typing the username in the new Post field. 2023-05-16 18:43:58 -04:00
Vitor Pamplona
1519e70b24 Increasing size of the "More Options" button in the Video Feed. 2023-05-16 18:03:24 -04:00
Vitor Pamplona
135596591a Optimizes the code for chat bubble rendering 2023-05-16 17:53:36 -04:00
Vitor Pamplona
389afcb600 Fixes Robohash dependency in size 2023-05-16 17:53:15 -04:00
Vitor Pamplona
d6c2966792 Adds color to relay icons 2023-05-16 17:53:02 -04:00
Vitor Pamplona
dd8b208a0d Avoiding creating a lnurl link with just those words 2023-05-16 17:52:39 -04:00
Vitor Pamplona
27182a77a4 v0.49.3 2023-05-16 12:25:16 -04:00
Vitor Pamplona
cf5f345d54 Formatting 2023-05-16 12:20:35 -04:00
Vitor Pamplona
3950517743 Improves rendering performance for chats. 2023-05-16 12:20:27 -04:00
L
d2accf7622 Update strings.xml
a few words . not urgent
2023-05-16 19:22:04 +03:30
Vitor Pamplona
7a14a1ab26 Formatting 2023-05-16 11:30:54 -04:00
Vitor Pamplona
9e93a35ee7 Parsing NIP-19 to improve search results. 2023-05-16 11:30:43 -04:00
Vitor Pamplona
76df70c6f6 Making sure filters return similar orders when two posts are published at the same time. 2023-05-16 11:10:17 -04:00
Vitor Pamplona
1d5ff5bea9 v0.49.2 2023-05-16 10:24:45 -04:00
Vitor Pamplona
1767d4a3a3 Enabling a log to help NIP95 relay developers track what the app is doing with their images. 2023-05-16 10:24:05 -04:00
Vitor Pamplona
f2d87b78b1 Fixing emoji's on chat handles 2023-05-16 10:12:31 -04:00
Vitor Pamplona
4bf76d1b79 v0.49.1 2023-05-16 09:47:18 -04:00
Vitor Pamplona
17fbf8c01f BugFix for incorrect caching of NIP95 2023-05-16 09:47:10 -04:00
Vitor Pamplona
332490376b Adjustments to Markdown detection and rendering. 2023-05-16 08:52:59 -04:00
Vitor Pamplona
14e670cd9a Avoid color-filtering the emoji 2023-05-16 08:32:25 -04:00
Vitor Pamplona
d29e6b4d0b Better shade for Pins 2023-05-16 08:32:03 -04:00
Vitor Pamplona
3558bab786 v0.49.0 2023-05-15 22:18:21 -04:00
Vitor Pamplona
7c21f077db Support for PinStr (33888) 2023-05-15 22:18:12 -04:00
Vitor Pamplona
a2a9d04e1e v0.48.0 2023-05-15 21:35:55 -04:00
Vitor Pamplona
fc56f3988e Support for NIP-30 2023-05-15 21:26:59 -04:00
Vitor Pamplona
95ad33ace2 Merge pull request #412 from Pextar/main
Swedish translation
2023-05-15 14:11:11 -04:00
Vitor Pamplona
cf850415b8 Merge branch 'main' into main 2023-05-15 14:11:06 -04:00
Vitor Pamplona
bd77bb7780 Merge remote-tracking branch 'origin/HEAD' 2023-05-15 14:09:56 -04:00
Vitor Pamplona
6a696bfa39 Adds a 5 minute expiration time for ImageErrors 2023-05-15 14:09:43 -04:00
Pextar
d5fc97184c Merge branch 'main' into main 2023-05-15 20:07:43 +02:00
Pextar
e0b9120e17 Create values-sv 2023-05-15 20:06:08 +02:00
Vitor Pamplona
1523a29a4f Merge pull request #410 from greenart7c3/main
add longPress to edit tor/orbot settings
2023-05-15 13:51:25 -04:00
Vitor Pamplona
1db6b4793f Adds NIP-94 shareable link to filestr 2023-05-15 13:25:21 -04:00
Vitor Pamplona
426a495eb4 Adds zapstr.live and listr.lol as shareable links 2023-05-15 13:17:31 -04:00
Vitor Pamplona
e07b4d49ef Fixes NPE on opening the QuickAction on a "Post Not Found" event. 2023-05-15 13:10:33 -04:00
Vitor Pamplona
595cab3630 v0.47.0 2023-05-15 11:16:50 -04:00
Vitor Pamplona
817a9c3e8d Adds support for AudioTrack Types from (https://zapstr.live/) 2023-05-15 11:15:32 -04:00
greenart7c3
32927040e3 add longPress to edit tor/orbot settings 2023-05-15 11:17:42 -03:00
Vitor Pamplona
c973d941cc v0.46.6 2023-05-14 22:23:37 -04:00
Vitor Pamplona
db6ebe609c Forces aspect ratio while obeying maxWidth and Height of the frame. 2023-05-14 22:22:53 -04:00
Vitor Pamplona
32913915ce Making sure remember does not interfere with translations. 2023-05-14 22:22:22 -04:00
Vitor Pamplona
4a8680e582 0.46.5 2023-05-14 21:06:06 -04:00
Vitor Pamplona
7cf0291b5a Applies remember functions to more parameters. 2023-05-14 21:05:58 -04:00
Vitor Pamplona
0406632e9d Moves internal content changes to derived states. 2023-05-14 20:24:41 -04:00
Vitor Pamplona
bf2949717c Makes sure to only change the visible status when needed. 2023-05-14 20:24:11 -04:00
Vitor Pamplona
bbea28c6df 0.46.4 2023-05-14 19:04:34 -04:00
Vitor Pamplona
1c76b7a35c Testing a not cropping version for videos. 2023-05-14 18:59:49 -04:00
Vitor Pamplona
814d05812a Fixes image cropping opening the image dialog. 2023-05-14 18:59:33 -04:00
Vitor Pamplona
b4d3cf87c8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:21:43 -04:00
Vitor Pamplona
e5ff92c804 v0.46.3 2023-05-14 18:21:33 -04:00
Vitor Pamplona
c3a31fd313 Update PRIVACY.md
Adds the collection of the push notification token to the Privacy.MD
2023-05-14 18:20:28 -04:00
Vitor Pamplona
3fd4c176e8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:12:16 -04:00
Vitor Pamplona
cf9395d5e3 Avoids Notification duplicates 2023-05-14 18:12:04 -04:00
Vitor Pamplona
89ae229ea2 Merge pull request #408 from vivganes/intl-hashtags
fix #407 correct non-english hashtags
2023-05-14 17:37:18 -04:00
Vitor Pamplona
f40b6c394c v0.46.2 2023-05-14 17:22:22 -04:00
Vitor Pamplona
f84501e40f Improvements to Poll Caching system: Fixes https://github.com/vitorpamplona/amethyst/issues/406 2023-05-14 17:03:46 -04:00
vivganes
d261003217 fix #407 correct non-english hashtags 2023-05-14 23:39:42 +05:30
Pextar
fc1ee54548 Merge branch 'vitorpamplona:main' into main 2023-05-14 08:29:09 +02:00
Vitor Pamplona
6842d12b39 Turn FollowList into a ViewModel and a LocalCache LiveData into a SharedFlow (it was killing some coroutines that were supposed to finish) 2023-05-13 12:49:48 -04:00
Vitor Pamplona
3e69b81d81 Moves aspect ratio to the appropriate composable 2023-05-13 12:46:57 -04:00
Vitor Pamplona
ea4e3e75d6 Formatting 2023-05-13 12:46:34 -04:00
Vitor Pamplona
5db04ef2b0 Allows the RichTextViewer to render before resolving urls. 2023-05-13 12:46:11 -04:00
Vitor Pamplona
b51186de30 updated secp lib 2023-05-12 17:18:56 -04:00
Vitor Pamplona
8fc0f5a8b8 Removing shadowed variable 2023-05-12 16:50:26 -04:00
Pextar
754ac9894b Delete values-sv 2023-05-12 20:38:08 +02:00
Vitor Pamplona
69744db53b Merge remote-tracking branch 'origin/HEAD' 2023-05-12 14:17:31 -04:00
Vitor Pamplona
db9a0ebfa1 Protects against exceptions when creating keys with invalid bytearrays. 2023-05-12 14:13:08 -04:00
Vitor Pamplona
38ebeded1c Merge pull request #402 from ZsZolee/main
TOR Hungarian translation
2023-05-12 13:40:41 -04:00
Vitor Pamplona
2b8fafc123 Merge pull request #403 from judemont/main
French translation for Android description.
2023-05-12 13:40:24 -04:00
Vitor Pamplona
fc2408b526 Correcting NPE from failing to load the thumbnail 2023-05-12 13:32:08 -04:00
JdM
6723806534 French translation for android description. 2023-05-12 17:26:11 +02:00
Zoltan
93c3a49ecf TOR Hungarian translation 2023-05-12 14:04:52 +02:00
Pextar
e80c8b37b3 Create values-sv 2023-05-12 12:14:20 +02:00
Vitor Pamplona
66b84c0dea 0.46.1 2023-05-11 18:30:18 -04:00
Vitor Pamplona
a7063042fb Formatting 2023-05-11 18:15:56 -04:00
Vitor Pamplona
aa60367c72 Doesn't update if it returned the same reference for the list. 2023-05-11 18:15:48 -04:00
Vitor Pamplona
19a58bb669 correctly refreshes the screen 2023-05-11 17:39:31 -04:00
Vitor Pamplona
55aafffb37 Remove logs 2023-05-11 17:39:21 -04:00
Vitor Pamplona
9b92dfca5e Isolating state changes in the profile page. 2023-05-11 17:31:32 -04:00
Vitor Pamplona
3ae75a3333 Adds FAB column to create new channels and join channels/users 2023-05-11 16:42:05 -04:00
Vitor Pamplona
9ebb3fbefc Improving reply screen to clarify which users will be notified. 2023-05-11 15:26:09 -04:00
Vitor Pamplona
96d66646b4 Displaying lists as Notes 2023-05-11 14:39:05 -04:00
Vitor Pamplona
f202422ff1 hasValidSignature crashes in some weirdly formed events. 2023-05-11 10:12:01 -04:00
Vitor Pamplona
f02e18ff9b Fix background color from remember calls 2023-05-11 09:32:33 -04:00
Vitor Pamplona
b72b0b028f v0.46.0 2023-05-11 09:19:27 -04:00
Vitor Pamplona
eb9be2108d Adjustments to Tor/Orbot screens 2023-05-11 09:18:17 -04:00
Vitor Pamplona
875eb0df78 Merge pull request #357 from greenart7c3/main
Add option to setup connection through Orbot
2023-05-11 08:33:33 -04:00
Vitor Pamplona
ea8a665bc7 Merge pull request #401 from Radiokot/fix/actualize_ru_uk_23_05_10
Actualize Russian and Ukrainian translations
2023-05-11 08:33:11 -04:00
Vitor Pamplona
142f850eb7 Merge pull request #400 from Bardesss/patch-5
Update strings.xml (NL)
2023-05-11 08:33:04 -04:00
Vitor Pamplona
9f12fffd0d Merge pull request #399 from malimahda/main
values-fa/strings
2023-05-11 08:32:46 -04:00
Vitor Pamplona
28a1f0d6da v0.45.1 2023-05-10 19:36:07 -04:00
Vitor Pamplona
b1de36f423 Trying to improve rendering by allowing recompositions to perform layout faster. 2023-05-10 19:08:56 -04:00
Oleg Koretsky
ff54d581ae Actualize Russian and Ukrainian translations 2023-05-10 22:57:41 +03:00
Vitor Pamplona
8fcd05a6bb avoids cutting the first paragraph of notifcations 2023-05-10 14:03:05 -04:00
Vitor Pamplona
434c1e9af3 Don't try to decrypt if message is blank 2023-05-10 14:02:53 -04:00
Vitor Pamplona
a62f2e44d0 Only calls invalidate if things change 2023-05-10 14:02:38 -04:00
Vitor Pamplona
aa0ace5014 Delaying a bit more the average update filter for relays. 2023-05-10 14:02:05 -04:00
greenart7c3
ee4c54aa27 use httpclient.getHttpClient 2023-05-10 14:47:46 -03:00
greenart7c3
7ff408bfb9 Merge branch 'main' into main 2023-05-10 14:42:37 -03:00
Vitor Pamplona
c1d127ac1e Fixing blank messages and Zapped DM content 2023-05-10 13:31:25 -04:00
Vitor Pamplona
0b2e5de752 Uppercase 2023-05-10 13:30:52 -04:00
Vitor Pamplona
920ce54fe3 v0.45.0 2023-05-10 11:20:28 -04:00
Vitor Pamplona
c87b246a4e Fixing tests 2023-05-10 11:19:32 -04:00
Vitor Pamplona
b8bc370bc1 Support for Push Notifications in the PlayStore build 2023-05-10 11:16:47 -04:00
L
a8069f7a3e Update strings.xml 2023-05-10 16:17:01 +03:30
L
ca9b7adfa0 Update strings.xml
a few words
2023-05-10 15:21:40 +03:30
L
3c3b6544cf Update strings.xml
changed a couple of words
2023-05-10 15:02:28 +03:30
Bardesss
910849b3c7 Update strings.xml (NL)
Updated translation
2023-05-10 08:55:43 +00:00
L
411ba0f137 Merge pull request #1 from believethehype/persian_translation
fixed path to Persian translation file
2023-05-09 23:02:36 +03:30
Believethehype
d48a15bfb3 Update strings.xml 2023-05-09 21:15:55 +02:00
Believethehype
489941106b fixed path of persian translation 2023-05-09 21:14:50 +02:00
Believethehype
a0391fb8a5 Merge branch 'pr/1' into persian_translation 2023-05-09 21:11:40 +02:00
Vitor Pamplona
633be54dd4 Updates dependencies 2023-05-09 13:20:59 -04:00
Vitor Pamplona
470281fa87 Filters lists out of the user's profile 2023-05-09 12:58:56 -04:00
Vitor Pamplona
3287196243 updating gradle plugin 2023-05-09 11:10:53 -04:00
Vitor Pamplona
bb8bb16370 Better regex for nostr:n.. citations in text. 2023-05-09 11:10:39 -04:00
Vitor Pamplona
4325975686 v0.44.0 2023-05-08 16:20:40 -04:00
Vitor Pamplona
53ec9d777f - Moving postValue to IO thread
- launching navigate in a thread
2023-05-08 16:10:16 -04:00
Vitor Pamplona
3e6e7d4863 - Checks if the file still exists before discarding a duplicate event.
- Adding a per list EOSE
2023-05-08 15:34:59 -04:00
Vitor Pamplona
81290f2b26 Moving follows to the known list. 2023-05-08 14:58:39 -04:00
Vitor Pamplona
4dcf38c492 Fixing tab sliders from jumping from one state to the other 2023-05-08 14:46:23 -04:00
Vitor Pamplona
04c1300317 Adding List Choice to Notifications 2023-05-08 11:04:37 -04:00
greenart7c3
0d051117b2 add toast message to strings.xml 2023-05-08 11:03:17 -03:00
greenart7c3
d31eca1b42 Merge branch 'main' into main 2023-05-08 10:48:48 -03:00
greenart7c3
a503a1b218 show orbot dialog at login 2023-05-08 10:45:10 -03:00
Vitor Pamplona
70890def82 v0.43.2 2023-05-08 09:43:31 -04:00
Vitor Pamplona
d0eadcf30d Formatting 2023-05-08 09:39:54 -04:00
Vitor Pamplona
ae268cc197 Fixing NostrFiles dev downloader 2023-05-08 09:39:44 -04:00
L
0bf317a818 Update strings.xml 2023-05-08 12:01:21 +03:30
L
915f7c07e8 Update strings.xml 2023-05-08 10:40:59 +03:30
L
69a1302404 Update strings.xml 2023-05-08 09:47:05 +03:30
Vitor Pamplona
d6d273e34c v0.43.1 2023-05-07 21:46:01 -04:00
Vitor Pamplona
462c73341f Fixes cutting text in the wrong language 2023-05-07 21:45:38 -04:00
Vitor Pamplona
aa07fbfc94 Adjusting NostrFilesDev to the latest API 2023-05-07 18:47:39 -04:00
Vitor Pamplona
59d7f7cc70 Merge pull request #396 from believethehype/nostrfilesdev
Added Nostrfiles.dev Image hoster Backend Code
2023-05-07 18:42:39 -04:00
Vitor Pamplona
950eefa4e3 Merge branch 'main' into nostrfilesdev 2023-05-07 18:42:09 -04:00
Vitor Pamplona
f2bbdd44a6 Removing file transform function 2023-05-07 18:40:24 -04:00
Vitor Pamplona
cb09a26ef5 Refactoring the nostr.build uploader 2023-05-07 18:37:33 -04:00
L
43156e3e11 Update strings.xml 2023-05-07 23:51:47 +03:30
Vitor Pamplona
eba14cf9f1 v0.43.0 2023-05-07 16:14:18 -04:00
Vitor Pamplona
13de5973ca Improving performance of the RichText Viewer 2023-05-07 16:12:28 -04:00
Vitor Pamplona
936f750826 Moving private messages and channel loading to a thread. 2023-05-07 16:11:40 -04:00
L
faf215110a Update strings.xml 2023-05-07 23:34:27 +03:30
L
839e00ae36 Update strings.xml 2023-05-07 22:50:37 +03:30
L
b829a42d76 Update strings.xml 2023-05-07 22:45:58 +03:30
L
e1c3191f31 Update strings.xml 2023-05-07 22:18:25 +03:30
Vitor Pamplona
f44549de41 Loading Users and Notes in Launched Effects only because of @Synchronized in the getOrCreate methods in LocalCache 2023-05-07 12:51:12 -04:00
Vitor Pamplona
1c0fcc4ab8 Solves NPE when loading an invalid npub in a citation 2023-05-07 12:50:17 -04:00
Vitor Pamplona
fcc6793244 Limiting AuthorGalleries to 50 people 2023-05-07 08:53:52 -04:00
Vitor Pamplona
dea35552d5 Calculating ZapAmount and converting to the screen before exiting the IO thread 2023-05-06 22:27:00 -04:00
Vitor Pamplona
b093873eb0 Avoids trying to decrypt a zap that was not sent by the author. 2023-05-06 22:17:43 -04:00
Vitor Pamplona
e5de2c45de Moving withContext to scope.launch to avoid waiting the results. 2023-05-06 21:58:25 -04:00
Vitor Pamplona
70d8d9e2e0 formatting 2023-05-06 20:59:24 -04:00
Vitor Pamplona
dc0fe75b3d Removing crossfades that might reduce the loading speed 2023-05-06 20:59:09 -04:00
Vitor Pamplona
25a34dd2e8 formatting 2023-05-06 20:03:04 -04:00
Vitor Pamplona
90227e1cdc Refactoring NIP05 verification 2023-05-06 20:02:54 -04:00
Vitor Pamplona
bba45dd0a7 Moving clickable to Row and not AsyncImage because it's faster 2023-05-06 19:19:57 -04:00
Vitor Pamplona
fdca0af97b Improving performance of the UserNameDisplay by making variables immutable 2023-05-06 19:18:15 -04:00
Vitor Pamplona
795120e932 Improving performance of Badge events 2023-05-06 19:17:39 -04:00
Vitor Pamplona
79da9cee01 NoteCompose refactoring 2023-05-06 11:49:53 -04:00
Vitor Pamplona
fbbb9b0d72 Increasing sensitivity for Language identification options 2023-05-06 09:53:44 -04:00
Vitor Pamplona
97d56d61d1 Caching Private Zap decryption 2023-05-06 09:46:25 -04:00
Vitor Pamplona
f3316f53ea Renames Hex.toByteArray() to avoid confusing with String.toByteArray() 2023-05-06 09:24:39 -04:00
Vitor Pamplona
e3ccf71a84 v0.42.5 2023-05-05 18:50:19 -04:00
Vitor Pamplona
635279d59a Testing removing the gradient of quoted posts. 2023-05-05 18:49:32 -04:00
Vitor Pamplona
0c673e91c6 BugFix for displaying other people's list after loading them from their profile 2023-05-05 18:49:13 -04:00
Vitor Pamplona
3519e20a98 BugFix for the empty selection 2023-05-05 18:48:37 -04:00
Vitor Pamplona
7167d57d22 v0.42.4 2023-05-05 13:49:41 -04:00
Vitor Pamplona
86adac95c9 Adds protections for calculating zap amounts without a private key 2023-05-05 13:49:18 -04:00
Vitor Pamplona
cffcc158f5 0.42.3 2023-05-05 13:29:20 -04:00
Vitor Pamplona
49a8778975 BugFix for NPE on null ZappedNote 2023-05-05 13:26:55 -04:00
Believethehype
dd62d2bae4 activated nostrfiles_dev for stories feed. 2023-05-05 19:25:31 +02:00
Believethehype
3c3b6c97c0 deactivate nostrfiles_dev selection for now (because of space) 2023-05-05 19:21:25 +02:00
Believethehype
dabeea43ea added hoster nostrfiles.dev 2023-05-05 18:59:24 +02:00
Vitor Pamplona
5b6b6f19e5 Reverting background refactoring due to color being stuck in recompositions. 2023-05-05 10:01:37 -04:00
Vitor Pamplona
28370dbc01 Merge pull request #395 from ZsZolee/main
Follow list Hungarian translation
2023-05-05 09:11:03 -04:00
greenart7c3
28521c1a60 add configuration screen when enabling tor in the drawer 2023-05-05 08:25:48 -03:00
greenart7c3
9c30547bd7 fix build 2023-05-05 06:34:14 -03:00
greenart7c3
b07fd253c7 Merge branch 'main' into main 2023-05-05 06:11:24 -03:00
Zoltan
ab841c6490 Follow list Hungarian translation 2023-05-05 10:38:53 +02:00
Vitor Pamplona
12ff434a07 v0.42.2 2023-05-04 22:35:08 -04:00
Vitor Pamplona
51432c8c50 Fixing the toByteArray call 2023-05-04 22:34:09 -04:00
Vitor Pamplona
483e7d673c Refactoring part of NoteCompose 2023-05-04 21:57:19 -04:00
Vitor Pamplona
3ce6ce9951 v0.42.1 2023-05-04 21:14:55 -04:00
Vitor Pamplona
84f51dc473 Removing animation from blurhash 2023-05-04 21:14:42 -04:00
Vitor Pamplona
5f4886ce52 Fixes migration of follow lists from old accounts 2023-05-04 21:10:09 -04:00
Vitor Pamplona
dea6f0ca21 v0.42.0 2023-05-04 20:10:01 -04:00
Vitor Pamplona
01bdc41286 Merge remote-tracking branch 'origin/HEAD' 2023-05-04 20:08:50 -04:00
Vitor Pamplona
8c2e89197e Adds support for Private and Public Follow Lists 2023-05-04 20:08:36 -04:00
Vitor Pamplona
712950c2a1 Merge pull request #393 from believethehype/hashtag_fixes
Update HashtagIcon.kt
2023-05-04 11:49:38 -04:00
Believethehype
95cc2ee589 Update HashtagIcon.kt 2023-05-04 13:29:00 +02:00
Vitor Pamplona
76283463b0 v0.41.0 2023-05-03 18:26:52 -04:00
Vitor Pamplona
5179a821b2 Adjustments to the image upload choice to make sure they fit in a usual screen. 2023-05-03 18:25:01 -04:00
Vitor Pamplona
974a8011f9 Merge remote-tracking branch 'origin/main' 2023-05-03 17:49:14 -04:00
Vitor Pamplona
771bb3fc27 Merge pull request #391 from believethehype/nostrbuild
Added example for Image upload on nostr.build
2023-05-03 17:48:50 -04:00
Vitor Pamplona
10bd63ca2f Merge pull request #392 from Bardesss/patch-4
Update strings.xml (NL)
2023-05-03 17:44:29 -04:00
Vitor Pamplona
1fad3d1c8a Testing the removal of the image proxy server. 2023-05-03 17:20:10 -04:00
Believethehype
67cc24c4d9 added nostr.build NIP94 2023-05-03 18:13:18 +02:00
Vitor Pamplona
4368b34569 Fixes tall images bleeding into other posts. 2023-05-03 10:21:52 -04:00
greenart7c3
264d6a40b3 increased the default timeout when using proxy 2023-05-03 09:00:14 -03:00
Bardesss
65540c8a95 Update strings.xml (NL)
Updated translations
2023-05-03 08:13:35 +00:00
Bardesss
b801aa6bd1 Update strings.xml (NL)
Updated translation
2023-05-03 08:08:15 +00:00
Believethehype
7224ad4b05 image upload works to nostr.build 2023-05-03 00:48:01 +02:00
Believethehype
b043e2da2c Merge remote-tracking branch 'upstream/main' into nostrbuild 2023-05-03 00:27:39 +02:00
Vitor Pamplona
f8a60203ec Adds NFC support for NIP19 URIs 2023-05-02 17:59:12 -04:00
Vitor Pamplona
7f18bc3a88 0.40.6 2023-05-02 17:11:56 -04:00
Vitor Pamplona
31740d8855 Adding Post Not found on NIP95 images that are not found in any relay. 2023-05-02 17:06:32 -04:00
Vitor Pamplona
8641e0fec5 Only showing the loading url after 200 ms to avoid blinking the text. 2023-05-02 17:04:06 -04:00
Vitor Pamplona
c8f1e13448 Formatting 2023-05-02 16:47:02 -04:00
Vitor Pamplona
580cf76577 BugFix on calculating the Zap amount. 2023-05-02 16:46:52 -04:00
Vitor Pamplona
61549ebfd3 BugFix for video saving tool 2023-05-02 16:03:04 -04:00
Vitor Pamplona
797d1bb2b4 Don't try to draw local image if the file does not exist 2023-05-02 15:53:44 -04:00
Vitor Pamplona
99c16879c1 Uses resizeMode according to context. 2023-05-02 15:53:15 -04:00
Vitor Pamplona
0bf80e88ce Avoids videos getting out of the current page and leaking into the next page. 2023-05-02 15:45:32 -04:00
Vitor Pamplona
a71679ef43 Removing the exposure of the URL from the initial image and video loads. 2023-05-02 15:44:59 -04:00
Vitor Pamplona
4bbb34e8c9 v0.40.5 2023-05-02 14:38:18 -04:00
Vitor Pamplona
0e1420a626 Adjust volume based on changing global mute variable. 2023-05-02 14:38:09 -04:00
Vitor Pamplona
79782ee8e7 Fixes the volume adjusting interfering with other apps. 2023-05-02 14:32:49 -04:00
Vitor Pamplona
81b70780a6 v0.40.4 2023-05-02 12:42:38 -04:00
Vitor Pamplona
277fc5971e Moving Home feed to start first when loading the app. 2023-05-02 12:31:03 -04:00
Vitor Pamplona
850bc15f4f More visible buttons in the Video feed. 2023-05-02 12:20:05 -04:00
Vitor Pamplona
6d2db65c12 Basic Mp3 Support 2023-05-02 11:48:38 -04:00
Vitor Pamplona
89ce6dbab2 Forces search types to be used by the search components and reorganizes search 2023-05-02 11:39:29 -04:00
Vitor Pamplona
80bfbf68b4 Sending dimensions to Image files. 2023-05-02 10:02:45 -04:00
Vitor Pamplona
4464247ddb NVK is beautiful. 2023-05-02 08:20:45 -04:00
Vitor Pamplona
486d43dc1b 0.40.3 2023-05-01 20:44:37 -04:00
Vitor Pamplona
6731ca59cd BugFix for mixing NIP94 and NIP95 selections 2023-05-01 20:43:31 -04:00
Vitor Pamplona
7d620cd89a BugFix for now showing the error message when the phone can't open the file 2023-05-01 20:43:14 -04:00
Vitor Pamplona
82a2586eab Adds language to warn that NIP94 and 95 are new and other clients might not see it. 2023-05-01 20:42:48 -04:00
Vitor Pamplona
5a3f912b73 Merge pull request #389 from vivganes/translation-ta
Add tamil translations
2023-05-01 20:06:56 -04:00
Vitor Pamplona
bc54134e65 0.40.2 2023-05-01 18:54:51 -04:00
Vitor Pamplona
0f080219b2 Starts on mute 2023-05-01 18:54:06 -04:00
Vitor Pamplona
ff54629601 Autoplay controls 2023-05-01 18:53:07 -04:00
Believethehype
f889ff2547 added nostrbuild, can only upload very small files right now 2023-05-02 00:23:59 +02:00
Vitor Pamplona
8f257a7e2d - Displays Video Uploading Progress
- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
2023-05-01 16:42:58 -04:00
Vitor Pamplona
9015089593 Correctly loading NIP95 events from relays. 2023-05-01 16:39:07 -04:00
Vitor Pamplona
2b87930283 Removing the need to prune NIP95 events. 2023-05-01 16:38:35 -04:00
vivganes
15d94ca9c4 Add tamil translations 2023-05-01 21:15:55 +05:30
Vitor Pamplona
44af7f958c Merge pull request #388 from vitorpamplona/zap_payments
Zap payments without zap services on the receiving end.
2023-05-01 11:22:01 -04:00
Vitor Pamplona
2b2028853e Finishes the payment database 2023-05-01 11:12:46 -04:00
Vitor Pamplona
66dcc03351 Merge remote-tracking branch 'origin/HEAD' into zap_payments 2023-05-01 08:55:52 -04:00
Vitor Pamplona
22ba150c4e v0.40.1 2023-04-30 21:35:53 -04:00
Vitor Pamplona
63cc00976f Fixes https://github.com/vitorpamplona/amethyst/issues/387 2023-04-30 21:34:19 -04:00
Vitor Pamplona
cc9d4c237a Fixes Bug with the Nostr's Report use of double asterisk 2023-04-30 21:31:37 -04:00
Vitor Pamplona
446135ea2a Adding Zap Payments to the calculation of amounts and zapped by 2023-04-30 21:30:30 -04:00
Vitor Pamplona
a38bbd7168 Adding hex index for the Video Feed 2023-04-30 17:00:47 -04:00
Vitor Pamplona
635ed1afc1 v0.40.0 2023-04-29 19:45:27 -04:00
Vitor Pamplona
130ad40a3a Merge pull request #385 from vitorpamplona/tiktok
Tiktok
2023-04-29 19:44:36 -04:00
Vitor Pamplona
be5e58111e Merge remote-tracking branch 'origin/HEAD' 2023-04-29 19:31:12 -04:00
Vitor Pamplona
8b012884b1 0.39.0 2023-04-29 19:31:00 -04:00
Vitor Pamplona
4949a5c459 Merge pull request #384 from ZsZolee/main
Upload server Hungarian translation
2023-04-29 19:30:06 -04:00
Vitor Pamplona
ab330fea96 v0.39.0 2023-04-29 19:29:42 -04:00
Vitor Pamplona
8209c45303 New Video Feed based on NIP-94 and 95 2023-04-29 19:28:46 -04:00
Zoltan
206935289e Upload server Hungarian translation 2023-04-29 10:45:16 +02:00
Vitor Pamplona
571ff0e72b v0.38.0 2023-04-28 18:42:44 -04:00
Vitor Pamplona
940914adf5 Merge remote-tracking branch 'origin/HEAD' 2023-04-28 18:40:32 -04:00
Vitor Pamplona
1496f012a5 Adds new Highlight event kind from https://highlighter.com/ 2023-04-28 18:40:12 -04:00
greenart7c3
20a65a0878 check if orbot is installed 2023-04-28 11:21:06 -03:00
greenart7c3
6285bfaae7 updated string.xml 2023-04-28 08:15:04 -03:00
Vitor Pamplona
2a552bea4d Update PRIVACY.md
Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage.
2023-04-27 19:30:20 -04:00
Vitor Pamplona
d1dc06a467 v0.37.4 2023-04-27 18:29:04 -04:00
Vitor Pamplona
f173f3194b Adjusts size of the Zap Forward text. 2023-04-27 18:27:56 -04:00
Vitor Pamplona
8ca63f32f4 Avoids flickering in the Global Feed 2023-04-27 18:20:04 -04:00
Vitor Pamplona
6b3e10ed28 Version 0.37.3 2023-04-27 17:37:23 -04:00
Vitor Pamplona
1ae785061e Reverting compose to 1.4.2 2023-04-27 17:35:53 -04:00
Vitor Pamplona
ddba4ac510 Add logs for fail to decrypt zap 2023-04-27 17:35:36 -04:00
Vitor Pamplona
0fe6bd6c83 Bugfix for clickable url 2023-04-27 17:13:56 -04:00
Vitor Pamplona
925289ae64 Reducing the amount of filters to send to relays to 10 2023-04-27 17:13:45 -04:00
Vitor Pamplona
f6f82a16f5 v0.37.2 2023-04-27 16:23:38 -04:00
Vitor Pamplona
54511dcc69 Updating dependencies 2023-04-27 16:23:30 -04:00
Vitor Pamplona
2f457d135f Improving translations 2023-04-27 15:54:26 -04:00
Vitor Pamplona
362082d608 Correcly copy pasting NIP94 and NIP95 addresses. 2023-04-27 15:40:28 -04:00
Vitor Pamplona
78cfa98456 Merge pull request #381 from vivganes/non-english-hashtags
Add support for non-english hashtags too
2023-04-27 15:28:38 -04:00
Vitor Pamplona
5e37a97d1e Merge branch 'main' into non-english-hashtags 2023-04-27 15:28:08 -04:00
Vitor Pamplona
561189144e v0.37.1 2023-04-27 15:06:04 -04:00
Vitor Pamplona
285a61e38f Enabling non NIP94 uploads for the PlayStore 2023-04-27 15:01:58 -04:00
Vitor Pamplona
2ca877b3c3 Zap Forwarding 2023-04-27 14:43:28 -04:00
vivganes
f105188738 Add support for non-english hashtags too 2023-04-27 21:13:25 +05:30
Vitor Pamplona
0a11de1892 Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files 2023-04-27 09:27:29 -04:00
Vitor Pamplona
1596a7b559 Pruning file storage events when pausing the app. 2023-04-27 09:26:26 -04:00
Vitor Pamplona
d4a3ee2651 Adding nip95 to readme 2023-04-26 19:04:01 -04:00
greenart7c3
77a1b2ba89 show a dialog when disabling tor 2023-04-26 14:41:36 -03:00
greenart7c3
3217d5095b created initProxy 2023-04-26 11:54:04 -03:00
greenart7c3
5d9d7ba16e created getProxy function 2023-04-26 10:19:30 -03:00
greenart7c3
c648f54b51 created HttpClient class 2023-04-26 10:03:51 -03:00
greenart7c3
4f8d33d850 fix build 2023-04-26 08:51:58 -03:00
greenart7c3
9f3d6a75e6 add proxy to all http clients 2023-04-26 08:42:49 -03:00
greenart7c3
ad420da6de added tor icon 2023-04-24 16:37:00 -03:00
greenart7c3
a354fe928b Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-24 08:16:55 -03:00
greenart7c3
cf8747b19f add a checkbox to use tor in the login screen, set the proxy in the account model construction 2023-04-20 15:30:04 -03:00
greenart7c3
2ae54b544f wip: add tor socks support 2023-04-20 08:21:11 -03:00
greenart7c3
bae1b6c6e6 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-03 13:55:06 -03:00
greenart7c3
ad2eabf386 add qrcode scanner to login page 2023-04-03 13:54:13 -03:00
237 changed files with 14342 additions and 4105 deletions

2
.idea/kotlinc.xml generated
View File

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

View File

@@ -1,12 +1,12 @@
# Amethyst Privacy Policy & Terms of Use
Effective as of Jan 20, 2023.
Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
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.
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
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.
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.

View File

@@ -15,7 +15,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
# Current Features
- [x] Event Builders / WebSocket Subscriptions (NIP-01, NIP-15)
- [x] Event Builders / WebSocket Subscriptions (NIP-01)
- [x] Home Feed
- [x] Notifications Feed
- [x] Global Feed
@@ -34,7 +34,8 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Lightning Tips
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
- [x] URI Support (NIP-21)
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [x] Created_at Limits (NIP-22)
- [x] Event Deletion (NIP-09)
- [x] Identity Verification (NIP-05)
- [x] Long-form Content (NIP-23)
- [x] Parameterized Replaceable Events (NIP-33)
@@ -43,7 +44,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Badges (NIP-58)
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable uploads (NIP-94)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
@@ -53,12 +54,19 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [x] Custom Emoji (NIP-30)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Generic Tags (NIP-12)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)
- [ ] Workspaces
@@ -67,8 +75,10 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [ ] Relay List Metadata (NIP-65)
- [ ] Signing Requests (NIP-46)
- [ ] Delegated Event Signing (NIP-26)
- [ ] Account Creation / Backup Guidance (NIP-06)
- [ ] Account Creation / Backup Guidance
- [ ] Mnemonic seed phrase (NIP-06)
- [ ] Message Sent feedback (NIP-20)
- [ ] OpenTimestamps Attestations (NIP-03)
# Development Overview

View File

@@ -2,6 +2,7 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jlleitschuh.gradle.ktlint' version "11.3.1"
id 'com.google.gms.google-services'
}
android {
@@ -12,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 127
versionName "0.37.0"
versionCode 187
versionName "0.53.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -23,6 +24,7 @@ android {
buildTypes {
release {
// TODO: Make sure all of JSON parsers work when activating these.
//minifyEnabled true
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
@@ -70,7 +72,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
kotlinCompilerExtensionVersion "1.4.7"
}
packagingOptions {
@@ -81,12 +83,11 @@ android {
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -114,7 +115,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.9.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -127,17 +128,20 @@ 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.0'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.1'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
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-alpha05'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
// important for proxy / tor
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -172,12 +176,25 @@ dependencies {
// Google services model the translate text
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
implementation "com.patrykandpatrick.vico:core:${vico_version}"
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
implementation "com.patrykandpatrick.vico:views:${vico_version}"
implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}"
// immutable collections to avoid recomposition
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"

68
app/google-services.json Normal file
View File

@@ -0,0 +1,68 @@
{
"project_info": {
"project_number": "768341258853",
"project_id": "amethyst-3057a",
"storage_bucket": "amethyst-3057a.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:768341258853:android:5d07c35a37b24ff36b8c8c",
"android_client_info": {
"package_name": "com.vitorpamplona.amethyst"
}
},
"oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:768341258853:android:e0200680f552484d6b8c8c",
"android_client_info": {
"package_name": "com.vitorpamplona.amethyst.debug"
}
},
"oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyB7ZxgdpgrN6R223HCFdfv4ulP8Egp7trE"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "768341258853-6um8ig59qstvio60gfo60fe5e45lnqqe.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}

View File

@@ -0,0 +1,89 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CitationTests {
val json = """
{
"content": "Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"created_at": 1683596206,
"id": "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758",
"kind": 1,
"pubkey": "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d",
"sig": "4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
"tags": [
[
"e",
"27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3",
"",
"root"
],
[
"e",
"be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc",
"",
"reply"
],
[
"p",
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
],
[
"p",
"22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
],
[
"p",
"3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
],
[
"p",
"ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
],
[
"p",
"0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
],
[
"p",
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
],
[
"p",
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
],
[
"p",
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
],
[
"p",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
]
],
"seenOn": [
"wss://nostr.wine/"
]
}
"""
@Test
fun parseEvent() {
val event = Event.fromJson(json, true) as TextNoteEvent
val expectedCitations = setOf(
"8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168",
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed",
"4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0",
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
)
assertEquals(expectedCitations, event.citedUsers())
}
}

View File

@@ -4,12 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
import com.vitorpamplona.amethyst.ui.actions.ImgurServer
import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
import com.vitorpamplona.amethyst.ui.actions.NostrFilesDevServer
import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.fail
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Base64
@@ -21,12 +21,12 @@ class ImageUploadTesting {
@Test()
fun testImgurUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
ImgurServer(),
onSuccess = { url, contentType ->
@@ -39,18 +39,17 @@ class ImageUploadTesting {
}
)
delay(1000)
delay(5000)
}
@Test()
@Ignore
fun testNostrBuildUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrBuildServer(),
onSuccess = { url, contentType ->
@@ -68,12 +67,12 @@ class ImageUploadTesting {
@Test()
fun testNostrImgUpload() = runBlocking {
val inputStream = Base64.getDecoder().decode(image).inputStream()
println("Uploading")
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrImgServer(),
onSuccess = { url, contentType ->
@@ -88,4 +87,27 @@ class ImageUploadTesting {
delay(1000)
}
@Test()
fun testNostrFilesDevUpload() = runBlocking {
val bytes = Base64.getDecoder().decode(image)
val inputStream = bytes.inputStream()
ImageUploader.uploadImage(
inputStream,
bytes.size.toLong(),
"image/gif",
NostrFilesDevServer(),
onSuccess = { url, contentType ->
println("Uploaded to $url")
assertNotNull(url)
},
onError = {
println("Failed to Upload")
fail("${it.message}")
}
)
delay(5000)
}
}

View File

@@ -78,7 +78,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)
@@ -127,8 +127,7 @@ class PrivateZapTests {
if (recepientPK != null && recepientPost != null) {
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
val decodedPrivateZap =
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
println(decodedPrivateZap?.toJson())
assertNotNull(decodedPrivateZap)

View File

@@ -0,0 +1,8 @@
package com.vitorpamplona.amethyst.service.notifications
import com.vitorpamplona.amethyst.AccountInfo
class PushNotificationUtils {
fun init(accounts: List<AccountInfo>) {
}
}

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -14,7 +13,7 @@ fun TranslatableRichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) = ExpandableRichTextViewer(
content,
canPreview,
@@ -22,5 +21,5 @@ fun TranslatableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
navController
nav
)

View File

@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.torproject.android"/>
</queries>
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-permission android:name="android.permission.INTERNET"/>
@@ -9,6 +11,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.NFC" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -32,6 +35,7 @@
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout"
android:theme="@style/Theme.Amethyst">
<intent-filter android:label="Amethyst">
@@ -46,6 +50,12 @@
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="nostr" />
</intent-filter>
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -63,6 +73,7 @@
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="fullSensor"
tools:replace="screenOrientation" />
</application>
</manifest>

View File

@@ -6,8 +6,11 @@ import android.content.SharedPreferences
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
@@ -46,10 +49,16 @@ private object PrefKeys {
const val ZAP_AMOUNTS = "zapAmounts"
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 ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val USE_PROXY = "use_proxy"
const val PROXY_PORT = "proxy_port"
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -197,15 +206,32 @@ object LocalPreferences {
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
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.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)
if (account.showSensitiveContent == null) {
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
} else {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
}
fun loadFromEncryptedStorage(): Account? {
encryptedPreferences(currentAccount()).apply {
val acc = loadFromEncryptedStorage(currentAccount())
acc?.registerObservers()
return acc
}
fun loadFromEncryptedStorage(npub: String?): Account? {
encryptedPreferences(npub).apply {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
@@ -217,6 +243,9 @@ object LocalPreferences {
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
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 zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -229,9 +258,9 @@ object LocalPreferences {
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
getString(PrefKeys.DEFAULT_FILE_SERVER, "NOSTR_BUILD"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
) ?: ServersAvailable.NOSTR_BUILD
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
@@ -266,9 +295,18 @@ object LocalPreferences {
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
} else {
null
}
val a = Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
hiddenUsers,
localRelays,
@@ -278,10 +316,16 @@ object LocalPreferences {
zapAmountChoices,
defaultZapType,
defaultFileServer,
defaultHomeFollowList,
defaultStoriesFollowList,
defaultNotificationFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
latestContactList,
proxy,
proxyPort,
showSensitiveContent
)
return a

View File

@@ -8,6 +8,7 @@ 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) {
@@ -38,19 +39,15 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(NotificationState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
fun refresh() {
postValue(NotificationState(cache))
}
}
class NotificationState(val cache: NotificationCache)

View File

@@ -1,7 +1,15 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.os.Build
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.model.Account
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.NostrChatroomListDataSource
@@ -12,20 +20,36 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null
fun start(account: Account) {
fun start(account: Account, context: Context) {
this.account = account
start()
start(context)
}
fun start() {
fun start(context: Context) {
val myAccount = account
// Resets Proxy Use
HttpClient.start(account)
Coil.setImageLoader {
ImageLoader.Builder(context).components {
if (Build.VERSION.SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
@@ -33,19 +57,17 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
// Notification Elements
NostrAccountDataSource.start()
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}
@@ -61,6 +83,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
NostrVideoDataSource.stop()
Client.disconnect()
}

View File

@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst
import android.content.Context
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.vitorpamplona.amethyst.service.HttpClient
object VideoCache {
@@ -31,7 +32,7 @@ object VideoCache {
cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
OkHttpDataSource.Factory(HttpClient.getHttpClient())
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}

View File

@@ -1,6 +1,9 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.FileHeader
@@ -19,6 +22,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
import java.math.BigDecimal
import java.net.Proxy
import java.util.Locale
val DefaultChannels = setOf(
@@ -35,7 +41,11 @@ fun getLanguagesSpokenByUser(): Set<String> {
return codedList
}
val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
@@ -46,11 +56,17 @@ class Account(
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int,
var showSensitiveContent: Boolean? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -169,20 +185,55 @@ class Account(
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
fun isNIP47Author(pubkeyHex: String?): Boolean {
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
if (privKey == null) return false
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
return (pubKey == pubkeyHex)
}
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
val myNip47 = zapPaymentRequest ?: return null
val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey
val pubKey = myNip47.pubKeyHex.hexToByteArray()
if (privKey == null) return null
return zapResponseEvent.response(privKey, pubKey)
}
fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
return zappedNote?.isZappedBy(userProfile(), this) == true
}
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray()
return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO
}
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
if (!isWriteable()) return
zapPaymentRequest?.let { nip47 ->
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
val wcListener = NostrLnZapPaymentResponseDataSource(
fromServiceHex = nip47.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
authSigningKey = nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!
)
wcListener.start()
LocalCache.consume(event) {
LocalCache.consume(event, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
val privKey = nip47.secret?.hexToByteArray()
if (privKey != null) {
onResponse(it.response(privKey, event.pubKey.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
}
}
@@ -375,12 +426,12 @@ class Account(
}
}
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null
val data = FileStorageEvent.create(
mimeType = headerInfo.mimeType ?: "",
data = data,
data = byteArray,
privateKey = loggedIn.privKey!!
)
@@ -389,16 +440,23 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
return Pair(data, signedEvent)
}
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
if (!isWriteable()) return null
Client.send(data)
LocalCache.consume(data)
LocalCache.consume(data, null)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
@@ -411,18 +469,26 @@ class Account(
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
fun sendPost(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
tags: List<String>? = null,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -435,6 +501,8 @@ class Account(
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!
)
@@ -450,7 +518,9 @@ class Account(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
closedAt: Int?,
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
) {
if (!isWriteable()) return
@@ -468,14 +538,16 @@ class Account(
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt
closedAt = closedAt,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive
)
// 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>?) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -487,13 +559,15 @@ class Account(
channel = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -506,6 +580,8 @@ class Account(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -538,18 +614,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -560,18 +651,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -582,18 +688,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -610,18 +731,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
val event = if (note is AddressableNote) {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.minus(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
loggedIn.privKey!!
)
} else {
BookmarkListEvent.create(
"bookmark",
bookmarks?.taggedEvents()?.minus(note.idHex),
bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
@@ -688,6 +824,24 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultHomeFollowList(name: String) {
defaultHomeFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeDefaultStoriesFollowList(name: String) {
defaultStoriesFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeDefaultNotificationFollowList(name: String) {
defaultNotificationFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -700,6 +854,37 @@ class Account(
saveable.invalidateData()
}
fun selectedUsersFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
val privKey = loggedIn.privKey
return if (listName != null) {
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
val list = LocalCache.addressables[aTag]
if (list != null) {
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
val privateHexList = privKey?.let {
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
} ?: emptySet()
(publicHexList + privateHexList).toSet()
} else {
emptySet()
}
} else {
emptySet()
}
}
fun selectedTagsFollowList(listName: String?): Set<String>? {
if (listName == GLOBAL_FOLLOWS) return null
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
return emptySet()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
@@ -724,15 +909,7 @@ class Account(
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.recipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -753,7 +930,7 @@ class Account(
val privateKeyToUse = loggedInPrivateKey
val pubkeyToUse = event.pubKey
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
} else {
// if the sender is logged in, these are the params
val altPubkeyToUse = recipientPK
@@ -773,9 +950,28 @@ class Account(
null
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
} else {
try {
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
)
}
result
} else {
null
}
} else {
null
}
} catch (e: Exception) {
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
null
}
}
@@ -821,7 +1017,7 @@ class Account(
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
} ?: return null
// Ugly, but forces nostr.band as the only search-supporting relay today.
@@ -831,7 +1027,8 @@ class Account(
Constants.forcedRelayForSearch.url,
Constants.forcedRelayForSearch.read,
Constants.forcedRelayForSearch.write,
Constants.forcedRelayForSearch.feedTypes
Constants.forcedRelayForSearch.feedTypes,
proxy
)
}
@@ -840,7 +1037,7 @@ class Account(
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
}.toTypedArray()
}
@@ -922,14 +1119,13 @@ class Account(
saveable.invalidateData()
}
init {
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show
saveable.invalidateData()
live.invalidateData()
}
fun registerObservers() {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
@@ -950,12 +1146,22 @@ class Account(
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
}
}
}
}
}
init {
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
}
}
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
@@ -975,4 +1181,5 @@ class AccountLiveData(private val account: Account) : LiveData<AccountState>(Acc
}
}
@Immutable
class AccountState(val account: Account)

View File

@@ -55,19 +55,15 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(AntiSpamState(cache))
}
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(AntiSpamState(cache))
}
}
class AntiSpamState(val cache: AntiSpamFilter)

View File

@@ -57,7 +57,7 @@ class Channel(val idHex: String) {
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.take(1000)
.toSet()
@@ -74,9 +74,9 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(ChannelState(channel))
}
}
@@ -84,10 +84,6 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
bundler.invalidate()
}
private fun refresh() {
postValue(ChannelState(channel))
}
override fun onActive() {
super.onActive()
NostrSingleChannelDataSource.add(channel.idHex)

View File

@@ -14,7 +14,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
"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", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.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))
else -> null
}

View File

@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
return toHex()
}
fun HexKey.toByteArray(): ByteArray {
fun HexKey.hexToByteArray(): ByteArray {
return Hex.decode(this)
}
@@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String {
fun decodePublicKey(key: String): ByteArray {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex?.toByteArray()
val pubKeyParsed = parsed?.hex?.hexToByteArray()
return if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey
@@ -50,6 +50,23 @@ fun decodePublicKey(key: String): ByteArray {
}
}
fun decodePublicKeyAsHexOrNull(key: String): HexKey? {
return try {
val parsed = Nip19.uriToRoute(key)
val pubKeyParsed = parsed?.hex
if (key.startsWith("nsec")) {
Persona(privKey = key.bechToBytes()).pubKey.toHexKey()
} else if (pubKeyParsed != null) {
pubKeyParsed
} else {
Hex.decode(key).toHexKey()
}
} catch (e: Exception) {
null
}
}
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {

View File

@@ -1,17 +1,22 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import 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
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -31,7 +36,8 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -154,6 +160,20 @@ object LocalCache {
}
}
fun consume(event: PeopleListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -261,6 +281,30 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: PinListEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
private fun consume(event: AudioTrackEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: BadgeDefinitionEvent) {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
@@ -328,7 +372,7 @@ object LocalCache {
}
}
fun consume(event: PrivateDmEvent, relay: Relay?) {
fun consume(event: PrivateDmEvent, relay: Relay?): Note {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -338,9 +382,9 @@ object LocalCache {
}
// Already processed this event.
if (note.event != null) return
if (note.event != null) return note
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
@@ -355,6 +399,8 @@ object LocalCache {
}
refreshObservers(note)
return note
}
fun consume(event: DeletionEvent) {
@@ -379,6 +425,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -387,7 +434,7 @@ object LocalCache {
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
@@ -506,17 +553,20 @@ object LocalCache {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey)
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing
}
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
@@ -531,16 +581,18 @@ object LocalCache {
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
} else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
val note = getOrCreateNote(event.id)
if (note.event == null) {
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
fun consume(event: ChannelMessageEvent, relay: Relay?) {
@@ -653,41 +705,90 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
fun consume(event: FileHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileStorageHeaderEvent) {
fun consume(event: FileStorageHeaderEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
fun consume(event: FileStorageEvent) {
fun consume(event: HighlightEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
// Adds to user profile
author.addNote(note)
refreshObservers(note)
}
fun consume(event: FileStorageEvent, relay: Relay?) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
note.loadEvent(event, author, emptyList())
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val file = File(cachePath, event.id)
if (!file.exists()) {
val stream = FileOutputStream(file)
stream.write(event.decode())
stream.close()
Log.i("FileStorageEvent", "NIP95 File received from ${relay?.url} and saved to disk as $file")
}
} catch (e: IOException) {
Log.e("FileStorageEvent", "FileStorageEvent save to disk error: " + event.id, e)
}
// Already processed this event.
if (note.event != null) return
// this is an invalid event. But we don't need to keep the data in memory.
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
note.loadEvent(eventNoData, author, emptyList())
refreshObservers(note)
}
@@ -696,12 +797,41 @@ object LocalCache {
// Does nothing without a response callback.
}
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
awaitingPaymentRequests.put(event.id, onResponse)
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
zappedNote?.addZapPayment(note, null)
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
refreshObservers(note)
}
fun consume(event: LnZapPaymentResponseEvent) {
val responseCallback = awaitingPaymentRequests[event.requestId()]
val requestId = event.requestId()
val pair = awaitingPaymentRequests[requestId] ?: return
val (zappedNote, responseCallback) = pair
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
// Already processed this event.
if (note.event != null) return
note.loadEvent(event, author, emptyList())
requestNote?.let { request ->
zappedNote?.addZapPayment(request, note)
}
if (responseCallback != null) {
responseCallback(event)
@@ -709,6 +839,12 @@ object LocalCache {
}
fun findUsersStartingWith(username: String): List<User> {
val key = decodePublicKeyAsHexOrNull(username)
if (key != null && users[key] != null) {
return listOfNotNull(users[key])
}
return users.values.filter {
(it.anyNameStartsWith(username)) ||
it.pubkeyHex.startsWith(username, true) ||
@@ -717,6 +853,16 @@ object LocalCache {
}
fun findNotesStartingWith(text: String): List<Note> {
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && (notes[key] ?: addressables[key]) != null) {
return listOfNotNull(notes[key] ?: addressables[key])
}
return notes.values.filter {
(it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
(it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) ||
@@ -732,6 +878,16 @@ object LocalCache {
}
fun findChannelsStartingWith(text: String): List<Channel> {
val key = try {
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
} catch (e: Exception) {
null
}
if (key != null && channels[key] != null) {
return listOfNotNull(channels[key])
}
return channels.values.filter {
it.anyNameStartsWith(text) ||
it.idHex.startsWith(text, true) ||
@@ -749,10 +905,6 @@ object LocalCache {
}
}
fun pruneFileStorageEvents(account: Account) {
notes.filter { it.value.event is FileStorageEvent }
}
fun pruneOldAndHiddenMessages(account: Account) {
channels.forEach { it ->
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
@@ -816,18 +968,74 @@ object LocalCache {
private fun refreshObservers(newNote: Note) {
live.invalidateData(newNote)
}
}
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
fun consume(event: Event, relay: Relay?) {
if (!event.hasValidSignature()) return
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.Main)
try {
when (event) {
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
is BookmarkListEvent -> consume(event)
is ChannelCreateEvent -> consume(event)
is ChannelHideMessageEvent -> consume(event)
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) {
postValue(bundledNewNotes)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)
is FileStorageHeaderEvent -> consume(event, relay)
is HighlightEvent -> consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let {
consume(it, relay)
}
consume(event)
}
is LnZapRequestEvent -> consume(event)
is LnZapPaymentRequestEvent -> consume(event)
is LnZapPaymentResponseEvent -> consume(event)
is LongTextNoteEvent -> consume(event, relay)
is MetadataEvent -> consume(event)
is PrivateDmEvent -> consume(event, relay)
is PinListEvent -> consume(event)
is PeopleListEvent -> consume(event)
is ReactionEvent -> consume(event)
is RecommendRelayEvent -> consume(event)
is ReportEvent -> consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let {
consume(it, relay)
}
consume(event)
}
is TextNoteEvent -> consume(event, relay)
is PollNoteEvent -> consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
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)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
_newEventBundles.emit(bundledNewNotes)
}
}
}

View File

@@ -1,7 +1,10 @@
package com.vitorpamplona.amethyst.model
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.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
@@ -19,6 +22,7 @@ import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
@@ -27,6 +31,7 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt()
}
@Stable
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
@@ -45,6 +50,8 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
@@ -156,6 +163,17 @@ open class Note(val idHex: String) {
}
}
fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -174,6 +192,17 @@ open class Note(val idHex: String) {
}
}
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
}
}
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -203,16 +232,24 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent)
} else {
null
}
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
}
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author === user }
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author === user }
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
@@ -233,12 +270,44 @@ open class Note(val idHex: String) {
}.flatten()
}
fun zappedAmount(): BigDecimal {
return zaps.mapNotNull { it.value?.event }
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts
val completedZaps = zaps.asSequence()
.mapNotNull { it.value?.event }
.filterIsInstance<LnZapEvent>()
.mapNotNull {
it.amount
}.sumOf { it }
.filter { it.amount != null }
.associate {
it.lnInvoice() to it.amount
}
.toMap()
val completedPayments = if (privKey != null && walletServicePubkey != null) {
// Payments confirmed by the User's Wallet
zapPayments
.asSequence()
.filter {
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
response is PayInvoiceSuccessResponse
}
.associate {
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
val amount = try {
if (lnInvoice == null) {
null
} else {
LnInvoiceUtil.getAmountInSats(lnInvoice)
}
} catch (e: java.lang.Exception) {
null
}
lnInvoice to amount
}
.toMap()
} else {
emptyMap()
}
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
}
fun hasPledgeBy(user: User): Boolean {
@@ -345,20 +414,16 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
if (hasActiveObservers()) {
refresh()
}
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
fun invalidateData() {
bundler.invalidate()
}
private fun refresh() {
postValue(NoteState(note))
}
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
@@ -378,4 +443,5 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
}
@Immutable
class NoteState(val note: Note)

View File

@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
testedNotes.add(note)

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.model
import com.baha.url.preview.BahaUrlPreview
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.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

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.model
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.model.BookmarkListEvent
@@ -20,6 +22,7 @@ import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
@Stable
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
@@ -181,7 +184,7 @@ class User(val pubkeyHex: String) {
}
@Synchronized
fun getOrCreatePrivateChatroom(user: User): Chatroom {
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
return privateChatrooms[user] ?: run {
val privateChatroom = Chatroom(setOf<Note>())
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
@@ -296,7 +299,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this === it.author }
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -358,6 +361,7 @@ data class RelayInfo(
data class Chatroom(var roomMessages: Set<Note>)
@Stable
class UserMetadata {
var name: String? = null
var username: String? = null
@@ -396,9 +400,9 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Main) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
refresh()
postValue(UserState(user))
}
}
@@ -406,10 +410,6 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
bundler.invalidate()
}
private fun refresh() {
postValue(UserState(user))
}
override fun onActive() {
super.onActive()
NostrSingleUserDataSource.add(user)
@@ -421,4 +421,5 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
}
@Immutable
class UserState(val user: User)

View File

@@ -59,7 +59,6 @@ object BlurHashRequester {
.Builder(context)
.data("bluehash:$encodedMessage")
.fetcherFactory(BlurHashFetcher.Factory)
.crossfade(100)
.build()
}
}

View File

@@ -4,8 +4,8 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import io.trbl.blurhash.BlurHash
import java.net.URL
import java.security.MessageDigest
import kotlin.math.roundToInt
@@ -14,24 +14,41 @@ class FileHeader(
val mimeType: String?,
val hash: String,
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
) {
companion object {
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
val imageData = URL(fileUrl).readBytes()
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
} else {
onError()
}
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
onError()
}
}
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
fun prepare(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
try {
val sha256 = MessageDigest.getInstance("SHA-256")
val hash = sha256.digest(data).toHexKey()
val size = data.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
@@ -47,20 +64,22 @@ class FileHeader(
mBitmap.height
)
val dim = "${mBitmap.width}x${mBitmap.height}"
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
if (aspectRatio > 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
} else {
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
}
} else {
null
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@@ -0,0 +1,34 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
object HttpClient {
private var proxy: Proxy? = null
fun start(account: Account?) {
this.proxy = account?.proxy
}
fun getHttpClient(): OkHttpClient {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
return OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.build()
}
fun getProxy(): Proxy? {
return proxy
}
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
}
}

View File

@@ -0,0 +1,32 @@
package com.vitorpamplona.amethyst.service
import java.util.regex.Pattern
class NIP30Parser {
val customEmojiPattern: Pattern = Pattern.compile("\\:([A-Za-z0-9_\\-]+)\\:", Pattern.CASE_INSENSITIVE)
fun buildArray(input: String): List<String> {
val matcher = customEmojiPattern.matcher(input)
val list = mutableListOf<String>()
while (matcher.find()) {
list.add(matcher.group())
}
if (list.isEmpty()) {
return listOf(input)
}
val regularChars = input.split(customEmojiPattern.toRegex())
var finalList = mutableListOf<String>()
var index = 0
for (e in regularChars) {
finalList.add(e)
if (index < list.size) {
finalList.add(list[index])
}
index++
}
return finalList
}
}

View File

@@ -9,12 +9,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
class Nip05Verifier() {
val client = HttpClient.getHttpClient()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
@@ -76,14 +75,17 @@ class Nip05Verifier {
fetchNip05Json(
nip05,
onSuccess = {
// 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.
val nip05url = try {
mapper.readTree(it)
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val user = nip05.split("@")[0].lowercase()
val hexKey = nip05url?.get("names")?.get(user)?.asText()

View File

@@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 100
)
)
}
@@ -75,7 +75,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
}
@@ -95,13 +95,13 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)
val accountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
}
override fun updateChannelFilters() {

View File

@@ -15,13 +15,14 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val chatRoomList = "ChatroomList"
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -30,7 +31,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -39,7 +40,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -48,7 +49,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList(),
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList
)
)
@@ -72,7 +73,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.relayList,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
)
)
@@ -80,7 +81,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
}
val chatroomListChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), chatRoomList, relayUrl, time)
}
override fun updateChannelFilters() {

View File

@@ -3,28 +3,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
@@ -52,8 +31,6 @@ abstract class NostrDataSource(val debugName: String) {
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in subscriptions.keys) {
if (!event.hasValidSignature()) return
val key = "$debugName $subscriptionId ${event.kind}"
val keyValue = eventCounter.get(key)
if (keyValue != null) {
@@ -62,49 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
eventCounter = eventCounter + Pair(key, Counter(1))
}
try {
when (event) {
is BadgeAwardEvent -> LocalCache.consume(event)
is BadgeDefinitionEvent -> LocalCache.consume(event)
is BadgeProfilesEvent -> LocalCache.consume(event)
is BookmarkListEvent -> LocalCache.consume(event)
is ChannelCreateEvent -> LocalCache.consume(event)
is ChannelHideMessageEvent -> LocalCache.consume(event)
is ChannelMessageEvent -> LocalCache.consume(event, relay)
is ChannelMetadataEvent -> LocalCache.consume(event)
is ChannelMuteUserEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> LocalCache.consume(event)
is FileStorageEvent -> LocalCache.consume(event)
is FileStorageHeaderEvent -> LocalCache.consume(event)
is LnZapEvent -> {
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is LnZapRequestEvent -> LocalCache.consume(event)
is LnZapPaymentRequestEvent -> LocalCache.consume(event)
is LnZapPaymentResponseEvent -> LocalCache.consume(event)
is LongTextNoteEvent -> LocalCache.consume(event, relay)
is MetadataEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
is RepostEvent -> {
event.containedPost()?.let { onEvent(it, subscriptionId, relay) }
LocalCache.consume(event)
}
is TextNoteEvent -> LocalCache.consume(event, relay)
is PollNoteEvent -> LocalCache.consume(event, relay)
else -> {
Log.w("Event Not Supported", event.toJson())
}
}
} catch (e: Exception) {
e.printStackTrace()
}
LocalCache.consume(event, relay)
}
}
@@ -168,7 +103,7 @@ abstract class NostrDataSource(val debugName: String) {
}
// Refreshes observers in batches.
private val bundler = BundledUpdate(250, Dispatchers.IO) {
private val bundler = BundledUpdate(300, Dispatchers.IO) {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay

View File

@@ -1,7 +1,10 @@
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
@@ -12,7 +15,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
@@ -25,7 +26,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
hashToLoad.capitalize()
)
),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
limit = 200
)
)

View File

@@ -2,7 +2,10 @@ 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.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.EOSEAccount
@@ -44,7 +47,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.followingKeySet()
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followKeys = follows.map {
it.substring(0, 6)
@@ -55,36 +58,36 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100,
since = latestEOSEs.users[account.userProfile()]?.relayList
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
)
)
}
val followAccountChannel = requestNewChannel { time, relayUrl ->
latestEOSEs.addOrUpdate(account.userProfile(), relayUrl, time)
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
}
override fun updateChannelFilters() {

View File

@@ -1,14 +1,18 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.TypedFilter
class NostrLnZapPaymentResponseDataSource(
private var fromServiceHex: String,
private var toUserHex: String,
private var replyingToHex: String
private val fromServiceHex: String,
private val toUserHex: String,
private val replyingToHex: String,
private val authSigningKey: ByteArray
) : NostrDataSource("LnZapPaymentResponseFeed") {
val feedTypes = setOf(FeedType.WALLET_CONNECT)
@@ -36,4 +40,14 @@ class NostrLnZapPaymentResponseDataSource(
channel.typedFilters = listOfNotNull(wc).ifEmpty { null }
}
override fun auth(relay: Relay, challenge: String) {
super.auth(relay, challenge)
val event = RelayAuthEvent.create(relay.url, challenge, authSigningKey)
Client.send(
event,
relay.url
)
}
}

View File

@@ -1,12 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.bechToBytes
import nostr.postr.toHex
import fr.acinq.secp256k1.Hex
object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private var searchString: String? = null
@@ -18,15 +19,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
}
val hexToWatch = try {
if (mySearchString.startsWith("npub") || mySearchString.startsWith("nsec")) {
decodePublicKey(mySearchString).toHex()
} else if (mySearchString.startsWith("note")) {
mySearchString.bechToBytes().toHex()
} else {
mySearchString
}
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
} catch (e: Exception) {
// Usually when people add an incomplete npub or note.
null
}
@@ -50,7 +44,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -58,9 +52,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
types = COMMON_FEED_TYPES,
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)

View File

@@ -29,7 +29,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
PollNoteEvent.kind
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
@@ -79,7 +79,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind
PollNoteEvent.kind,
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -117,7 +120,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
ids = interestedEvents.toList()
)
@@ -150,22 +154,30 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
fun add(eventId: Note) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
if (!eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.plus(eventId)
invalidateFilters()
}
}
fun remove(eventId: Note) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
if (eventsToWatch.contains(eventId)) {
eventsToWatch = eventsToWatch.minus(eventId)
invalidateFilters()
}
}
fun addAddress(aTag: Note) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
invalidateFilters()
}
}
}

View File

@@ -64,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
}
fun add(user: User) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
if (!usersToWatch.contains(user)) {
usersToWatch = usersToWatch.plus(user)
invalidateFilters()
}
}
fun remove(user: User) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
if (usersToWatch.contains(user)) {
usersToWatch = usersToWatch.minus(user)
invalidateFilters()
}
}
}

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
@@ -10,14 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String?) {
if (userId != null) {
user = LocalCache.getOrCreateUser(userId)
} else {
user = null
}
resetFilters()
fun loadUserProfile(user: User?) {
this.user = user
}
fun createUserInfoFilter() = user?.let {
@@ -35,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -88,7 +81,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BookmarkListEvent.kind),
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 1
)

View File

@@ -0,0 +1,54 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
lateinit var account: Account
fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}

View File

@@ -2,6 +2,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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.math.BigDecimal
import java.net.URLEncoder
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
class LightningAddressResolver() {
val client = HttpClient.getHttpClient()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")

View File

@@ -4,7 +4,7 @@ import java.util.regex.Pattern
object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile(
"lnurl.*",
"lnurl.+",
Pattern.CASE_INSENSITIVE
)

View File

@@ -1,7 +1,8 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
import fr.acinq.secp256k1.Hex
@@ -9,12 +10,13 @@ import nostr.postr.Bech32
import nostr.postr.bechToBytes
import nostr.postr.toByteArray
@Immutable
data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val relay: String?) {
fun toTag() = "$kind:$pubKeyHex:$dTag"
fun toNAddr(): String {
val kind = kind.toByteArray()
val author = pubKeyHex.toByteArray()
val author = pubKeyHex.hexToByteArray()
val dTag = dTag.toByteArray(Charsets.UTF_8)
val relay = relay?.toByteArray(Charsets.UTF_8)

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class AudioTrackEvent(
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 participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == PRICE }?.get(1)
fun cover() = tags.firstOrNull { it.size > 1 && it[0] == COVER }?.get(1)
fun subject() = tags.firstOrNull { it.size > 1 && it[0] == SUBJECT }?.get(1)
fun media() = tags.firstOrNull { it.size > 1 && it[0] == MEDIA }?.get(1)
companion object {
const val kind = 31337
private const val TYPE = "c"
private const val PRICE = "price"
private const val COVER = "cover"
private const val SUBJECT = "subject"
private const val MEDIA = "media"
fun create(
type: String,
media: String,
price: String? = null,
cover: String? = null,
subject: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): AudioTrackEvent {
val tags = listOfNotNull(
listOf(MEDIA, media),
listOf(TYPE, type),
price?.let { listOf(PRICE, it) },
cover?.let { listOf(COVER, it) },
subject?.let { listOf(SUBJECT, it) }
)
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return AudioTrackEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Participant(val key: String, val role: String?)

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeAwardEvent(
id: HexKey,
pubKey: HexKey,
@@ -10,13 +12,9 @@ class BadgeAwardEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
fun awardees() = taggedUsers()
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun awardDefinition() = taggedAddresses()
companion object {
const val kind = 8

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeDefinitionEvent(
id: HexKey,
pubKey: HexKey,
@@ -9,14 +11,14 @@ class BadgeDefinitionEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : 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 name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
companion object {
const val kind = 30009

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
@Immutable
class BadgeProfilesEvent(
id: HexKey,
pubKey: HexKey,
@@ -9,7 +11,7 @@ class BadgeProfilesEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
fun badgeAwardEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun badgeAwardDefinitions() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
@@ -18,8 +20,8 @@ class BadgeProfilesEvent(
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 30008

View File

@@ -1,10 +1,13 @@
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.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
@Immutable
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -18,6 +21,7 @@ open class BaseTextNoteEvent(
open fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
private var citedUsersCache: Set<HexKey>? = null
private var citedNotesCache: Set<HexKey>? = null
fun citedUsers(): Set<HexKey> {
citedUsersCache?.let { return it }
@@ -41,17 +45,18 @@ open class BaseTextNoteEvent(
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
try {
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
try {
if (parsed != null) {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
if (tag != null && tag[0] == "p") {
returningList.add(tag[1])
}
} catch (e: Exception) {
}
} catch (e: Exception) {
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
}
}
@@ -59,8 +64,10 @@ open class BaseTextNoteEvent(
return returningList
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
fun findCitations(): Set<HexKey> {
citedNotesCache?.let { return it }
val citations = mutableSetOf<HexKey>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
@@ -86,10 +93,6 @@ open class BaseTextNoteEvent(
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
@@ -104,6 +107,7 @@ open class BaseTextNoteEvent(
}
}
citedNotesCache = citations
return citations
}

View File

@@ -1,13 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class BookmarkListEvent(
id: HexKey,
pubKey: HexKey,
@@ -15,50 +14,7 @@ class BookmarkListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30001
@@ -77,24 +33,7 @@ class BookmarkListEvent(
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
val content = Utils.encrypt(
msg,
privateKey,
pubKey
)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))

View File

@@ -1,11 +1,13 @@
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.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelCreateEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelHideMessageEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMessageEvent(
id: HexKey,
pubKey: HexKey,
@@ -20,7 +22,16 @@ class ChannelMessageEvent(
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(
message: String,
channel: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
markAsSensitive: Boolean
): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -32,6 +43,12 @@ class ChannelMessageEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)

View File

@@ -1,11 +1,13 @@
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.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMetadataEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMuteUserEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
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.decodePublicKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
class ContactListEvent(

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class DeletionEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
@@ -13,6 +15,7 @@ import java.math.BigDecimal
import java.security.MessageDigest
import java.util.*
@Immutable
open class Event(
val id: HexKey,
@SerializedName("pubkey") val pubKey: HexKey,
@@ -38,9 +41,21 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
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 zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
@@ -79,7 +94,7 @@ open class Event(
override fun getReward(): BigDecimal? {
return try {
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
} catch (e: Exception) {
null
}
@@ -104,11 +119,12 @@ open class Event(
}
override fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
return try {
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
} catch (e: Exception) {
Log.e("Event", "Fail checking if event $id has a valid signature", e)
false
}
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
}
private fun generateId(): String {
@@ -203,6 +219,8 @@ open class Event(
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -210,6 +228,7 @@ 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) {
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
@@ -225,12 +244,15 @@ open class Event(
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)
HighlightEvent.kind -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PinListEvent.kind -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
@@ -270,3 +292,9 @@ open class Event(
}
}
}
@Immutable
interface AddressableEvent {
fun dTag(): String
fun address(): ATag
}

View File

@@ -1,8 +1,10 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import java.math.BigDecimal
@Immutable
interface EventInterface {
fun id(): HexKey
@@ -33,4 +35,7 @@ interface EventInterface {
fun getReward(): BigDecimal?
fun getPoWRank(): Int
fun zapAddress(): String?
fun isSensitive(): Boolean
}

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -19,6 +21,7 @@ class FileHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@@ -30,6 +33,7 @@ class FileHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@@ -41,6 +45,7 @@ class FileHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@@ -53,6 +58,7 @@ class FileHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },

View File

@@ -1,12 +1,14 @@
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.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
@Immutable
class FileStorageEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileStorageHeaderEvent(
id: HexKey,
pubKey: HexKey,
@@ -20,6 +22,7 @@ class FileStorageHeaderEvent(
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
@@ -30,6 +33,7 @@ class FileStorageHeaderEvent(
private const val ENCRYPTION_KEY = "aes-256-gcm"
private const val MIME_TYPE = "m"
private const val FILE_SIZE = "size"
private const val DIMENSION = "dim"
private const val HASH = "x"
private const val MAGNET_URI = "magnet"
private const val TORRENT_INFOHASH = "i"
@@ -41,6 +45,7 @@ class FileStorageHeaderEvent(
description: String? = null,
hash: String? = null,
size: String? = null,
dimensions: String? = null,
blurhash: String? = null,
magnetURI: String? = null,
torrentInfoHash: String? = null,
@@ -53,6 +58,7 @@ class FileStorageHeaderEvent(
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
size?.let { listOf(FILE_SIZE, it) },
dimensions?.let { listOf(DIMENSION, it) },
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },

View File

@@ -0,0 +1,94 @@
package com.vitorpamplona.amethyst.service.model
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.hexToByteArray
import nostr.postr.Utils
@Immutable
abstract class GeneralListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun category() = dTag()
fun bookmarkedPosts() = taggedEvents()
fun bookmarkedPeople() = taggedUsers()
fun plainContent(privKey: ByteArray): String? {
if (content.isBlank()) return null
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
null
}
}
@Transient
private var privateTagsCache: List<List<String>>? = null
fun privateTags(privKey: ByteArray): List<List<String>>? {
if (privateTagsCache != null) {
return privateTagsCache
}
privateTagsCache = try {
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
} catch (e: Throwable) {
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
null
}
return privateTagsCache
}
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
companion object {
fun createPrivateTags(
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
pubKey: ByteArray
): String {
val privTags = mutableListOf<List<String>>()
privEvents?.forEach {
privTags.add(listOf("e", it))
}
privUsers?.forEach {
privTags.add(listOf("p", it))
}
privAddresses?.forEach {
privTags.add(listOf("a", it.toTag()))
}
val msg = gson.toJson(privTags)
return Utils.encrypt(
msg,
privateKey,
pubKey
)
}
}
}

View File

@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class HighlightEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun inUrl() = taggedUrls().firstOrNull()
fun author() = taggedUsers().firstOrNull()
fun quote() = content
companion object {
const val kind = 9802
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -1,10 +1,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.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
@Immutable
class LnZapEvent(
id: HexKey,
pubKey: HexKey,
@@ -57,7 +59,7 @@ class LnZapEvent(
return content
}
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import java.math.BigDecimal
@Immutable
interface LnZapEventInterface : EventInterface {
fun zappedPost(): List<String>

View File

@@ -1,12 +1,19 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
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.toByteArray
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(
id: HexKey,
pubKey: HexKey,
@@ -16,13 +23,27 @@ class LnZapPaymentRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var lnInvoice: String? = null
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun lnInvoice(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
if (lnInvoice != null) {
return lnInvoice
}
return Utils.decrypt(content, sharedSecret)
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
val jsonText = Utils.decrypt(content, sharedSecret)
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
return lnInvoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -39,12 +60,12 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
serializedRequest,
privateKey,
walletServicePubkey.toByteArray()
walletServicePubkey.hexToByteArray()
)
val tags = mutableListOf<List<String>>()
@@ -59,11 +80,37 @@ class LnZapPaymentRequestEvent(
// REQUEST OBJECTS
abstract class Request(val method: String, val params: Params)
abstract class Params
abstract class Request(var method: String? = null)
// PayInvoice Call
class PayInvoiceParams(var invoice: String? = null)
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
class PayInvoiceParams(val invoice: String) : Params()
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
companion object {
fun create(bolt11: String): PayInvoiceMethod {
return PayInvoiceMethod(PayInvoiceParams(bolt11))
}
}
}
class RequestDeserializer :
JsonDeserializer<Request?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Request? {
val jsonObject = json.asJsonObject
val method = jsonObject.get("method")?.asString
if (method == "pay_invoice") {
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
}
return null
}
companion object {
}
}

View File

@@ -1,10 +1,17 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
@Immutable
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,
@@ -14,10 +21,14 @@ class LnZapPaymentResponseEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
// Once one of an app user decrypts the payment, all users else can see it.
@Transient
private var response: Response? = null
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -34,15 +45,21 @@ class LnZapPaymentResponseEvent(
}
}
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
} else {
fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
if (response != null) response
return try {
if (content.isNotEmpty()) {
val decrypted = decrypt(privKey, pubKey)
response = gson.fromJson(decrypted, Response::class.java)
response
} else {
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
} catch (e: Exception) {
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
null
}
companion object {
@@ -58,7 +75,7 @@ abstract class Response(
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
@@ -86,3 +103,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
OTHER // Other error.
}
}
class ResponseDeserializer :
JsonDeserializer<Response?> {
@Throws(JsonParseException::class)
override fun deserialize(
json: JsonElement,
typeOfT: Type,
context: JsonDeserializationContext
): Response? {
val jsonObject = json.asJsonObject
val resultType = jsonObject.get("result_type")?.asString
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")?.asJsonObject
val error = jsonObject.get("error")?.asJsonObject
if (result != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
}
return null
}
companion object {
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
@@ -11,6 +12,7 @@ import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
@Immutable
class LnZapRequestEvent(
id: HexKey,
pubKey: HexKey,
@@ -20,12 +22,37 @@ class LnZapRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
private var privateZapEvent: Event? = null
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
if (privateZapEvent != null) return privateZapEvent
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
privateZapEvent = decryptedEvent
return privateZapEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
companion object {
const val kind = 9734
@@ -46,7 +73,7 @@ class LnZapRequestEvent(
listOf("p", originalNote.pubKey()),
listOf("relays") + relays
)
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
if (pollOption != null && pollOption >= 0) {
@@ -59,7 +86,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = "" // make sure public content is empty, as the content is encrypted
privkey = encryptionPrivateKey // sign event with generated privkey
@@ -92,7 +119,7 @@ class LnZapRequestEvent(
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
tags = tags + listOf(listOf("anon", encryptedContent))
content = ""
privkey = encryptionPrivateKey
@@ -109,7 +136,7 @@ class LnZapRequestEvent(
return sha256.digest(strbyte)
}
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
@@ -128,7 +155,7 @@ class LnZapRequestEvent(
return encryptedMsgBech32 + "_" + ivBech32
}
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
throw IllegalArgumentException("Invalid shared secret size")
@@ -150,61 +177,5 @@ class LnZapRequestEvent(
throw IllegalArgumentException("Bad padding: ${ex.message}")
}
}
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) {
val encnote = anonTag[1]
if (encnote.isNotBlank()) {
try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) {
return decryptedEvent
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
return null
}
}
}
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
"content": "",
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
"created_at": 1674164539,
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
"kind": 9734,
"tags": [
[
"e",
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
],
[
"p",
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
],
[
"relays",
"wss://relay.damus.io",
"wss://nostr-relay.wlvs.space",
"wss://nostr.fmt.wiz.biz",
"wss://relay.nostr.bg",
"wss://nostr.oxtr.dev",
"wss://nostr.v0l.io",
"wss://brb.io",
"wss://nostr.bitcoiner.social",
"ws://monad.jb55.com:8080",
"wss://relay.snort.social"
],
[
"poll_option", "n"
]
],
"ots": <base64-encoded OTS file data> // TODO
}
*/

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LongTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -12,9 +14,9 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun topics() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
fun title() = tags.filter { it.firstOrNull() == "title" }.mapNotNull { it.getOrNull(1) }.firstOrNull()

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ContactMetaData(
val name: String,
val picture: String,

View File

@@ -1,13 +1,15 @@
package com.vitorpamplona.amethyst.service.model
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.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
@@ -15,13 +17,13 @@ class MuteListEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun plainContent(privKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
return Utils.decrypt(content, sharedSecret)
} catch (e: Exception) {

View File

@@ -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.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PeopleListEvent(
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 = 30000
fun create(
name: String = "",
events: List<String>? = null,
users: List<String>? = null,
addresses: List<ATag>? = null,
privEvents: List<String>? = null,
privUsers: List<String>? = null,
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
events?.forEach {
tags.add(listOf("e", it))
}
users?.forEach {
tags.add(listOf("p", it))
}
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -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.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PinListEvent(
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 pins() = tags.filter { it.size > 1 && it[0] == "pin" }.map { it[1] }
companion object {
const val kind = 33888
fun create(
pins: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): PinListEvent {
val tags = mutableListOf<List<String>>()
pins.forEach {
tags.add(listOf("pin", it))
}
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return PinListEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@@ -11,6 +12,7 @@ const val VALUE_MINIMUM = "value_minimum"
const val CONSENSUS_THRESHOLD = "consensus_threshold"
const val CLOSED_AT = "closed_at"
@Immutable
class PollNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -48,7 +50,9 @@ class PollNoteEvent(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
closedAt: Int?,
zapReceiver: String?,
markAsSensitive: Boolean
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -68,6 +72,14 @@ class PollNoteEvent(
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString()))
if (zapReceiver != null) {
tags.add(listOf("zap", zapReceiver))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -1,6 +1,7 @@
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.toHexKey
import fr.acinq.secp256k1.Hex
@@ -8,6 +9,7 @@ import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
@Immutable
class PrivateDmEvent(
id: HexKey,
pubKey: HexKey,
@@ -21,7 +23,15 @@ class PrivateDmEvent(
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
* for initial messages.
*/
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
private fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
}
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
@@ -31,6 +41,11 @@ class PrivateDmEvent(
*/
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
return try {
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
@@ -58,10 +73,12 @@ class PrivateDmEvent(
msg: String,
replyTos: List<String>? = null,
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
advertiseNip18: Boolean = true,
markAsSensitive: Boolean
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) { nip18Advertisement } else { "" } + msg,
@@ -79,6 +96,12 @@ class PrivateDmEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ReactionEvent(
id: HexKey,
pubKey: HexKey,
@@ -32,7 +34,7 @@ class ReactionEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
if (originalNote is LongTextNoteEvent) {
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}

View File

@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.net.URI
import java.util.Date
@Immutable
class RecommendRelayEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,10 +1,12 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelayAuthEvent(
id: HexKey,
pubKey: HexKey,

View File

@@ -1,13 +1,16 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
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)
// NIP 56 event.
@Immutable
class ReportEvent(
id: HexKey,
pubKey: HexKey,
@@ -63,7 +66,7 @@ class ReportEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = listOf(reportPostTag, reportAuthorTag)
if (reportedPost is LongTextNoteEvent) {
if (reportedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", reportedPost.address().toTag()))
}

View File

@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class RepostEvent(
id: HexKey,
pubKey: HexKey,
@@ -36,7 +38,7 @@ class RepostEvent(
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags: List<List<String>> = boostedPost.tags().plus(listOf(replyToPost, replyToAuthor))
if (boostedPost is LongTextNoteEvent) {
if (boostedPost is AddressableEvent) {
tags = tags + listOf(listOf("a", boostedPost.address().toTag()))
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@Immutable
class TextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -26,6 +28,8 @@ class TextNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -42,13 +46,21 @@ class TextNoteEvent(
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
tags.add(listOf("t", it.lowercase()))
}
extraTags?.forEach {
tags.add(listOf("t", it))
}
zapReceiver?.let {
tags.add(listOf("zap", it))
}
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
if (markAsSensitive) {
tags.add(listOf("content-warning", ""))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Bech32
import nostr.postr.bechToBytes
@@ -13,14 +13,14 @@ object Nip19 {
USER, NOTE, EVENT, RELAY, ADDRESS
}
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
data class Return(
val type: Type,
val hex: String,
val relay: String? = null,
val author: String? = null,
val kind: Long? = null,
val kind: Int? = null,
val additionalChars: String = ""
)
@@ -109,7 +109,7 @@ object Nip19 {
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }?.toLong()
?.let { Tlv.toInt32(it) }
return Return(Type.EVENT, hex, relay, author, kind)
}
@@ -140,15 +140,15 @@ object Nip19 {
val kind = tlv.get(Tlv.Type.KIND.id)
?.get(0)
?.let { Tlv.toInt32(it) }?.toLong()
?.let { Tlv.toInt32(it) }
return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind)
}
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
val kind = kind?.toByteArray()
val author = author?.toByteArray()
val idHex = idHex.toByteArray()
val author = author?.hexToByteArray()
val idHex = idHex.hexToByteArray()
val relay = relay?.toByteArray(Charsets.UTF_8)
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex

View File

@@ -0,0 +1,99 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.Context
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
import com.vitorpamplona.amethyst.ui.note.showAmount
class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) {
// adds to database
LocalCache.consume(event, null)
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
}
}
private fun notify(event: PrivateDmEvent) {
val note = LocalCache.notes[event.id] ?: return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet()
val messagingWith = acc.userProfile().privateChatrooms.keys.filter {
(
it.pubkeyHex in followingKeySet || acc.userProfile()
.hasSentMessagesTo(it)
) && !acc.isHidden(it)
}.toSet()
if (note.author in messagingWith) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
}
}
}
private fun notify(event: LnZapEvent) {
val noteZapEvent = LocalCache.notes[event.id] ?: return
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest?.event as? LnZapRequestEvent)?.let {
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
Pair(author, decryptedContent.content)
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
}
}
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
}
}
}
private fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
}
}

View File

@@ -0,0 +1,185 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat
import coil.ImageLoader
import coil.executeBlocking
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
dmChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_dms_channel_id),
applicationContext.getString(R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(dmChannel!!)
return dmChannel!!
}
private fun getOrCreateZapChannel(applicationContext: Context): NotificationChannel {
if (zapChannel != null) return zapChannel!!
zapChannel = NotificationChannel(
applicationContext.getString(R.string.app_notification_zaps_channel_id),
applicationContext.getString(R.string.app_notification_zaps_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = applicationContext.getString(R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(zapChannel!!)
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendDMNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
applicationContext: Context
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
}
fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
pictureUrl: String?,
uri: String,
channelId: String,
applicationContext: Context
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext)
.data(pictureUrl)
.build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = imageResult.drawable as? BitmapDrawable,
uri = uri,
channelId,
applicationContext = applicationContext
)
} else {
sendNotification(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
picture = null,
uri = uri,
channelId,
applicationContext = applicationContext
)
}
}
private fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
picture: BitmapDrawable?,
uri: String,
channelId: String,
applicationContext: Context
) {
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
data = Uri.parse(uri)
}
val contentPendingIntent = PendingIntent.getActivity(
applicationContext,
id.hashCode(),
contentIntent,
PendingIntent.FLAG_MUTABLE
)
// Build the notification
val builderPublic = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(applicationContext.getString(R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
// Build the notification
val builder = NotificationCompat.Builder(
applicationContext,
channelId
)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.setGroup(messageTitle)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
notify(id.hashCode(), builder.build())
}
/**
* Cancels all notifications.
*/
fun NotificationManager.cancelNotifications() {
cancelAll()
}
}

View File

@@ -0,0 +1,73 @@
package com.vitorpamplona.amethyst.service.notifications
import android.util.Log
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class RegisterAccounts(
private val accounts: List<AccountInfo>
) {
// creates proof that it controls all accounts
private fun signEventsToProveControlOfAccounts(
accounts: List<AccountInfo>,
notificationToken: String
): List<RelayAuthEvent> {
return accounts.mapNotNull {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null) {
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
if (relayToUse != null) {
acc.createAuthEvent(relayToUse, notificationToken)
} else {
null
}
} else {
null
}
}
}
private fun postRegistrationEvent(events: List<RelayAuthEvent>) {
try {
val jsonObject = """{
"events": [ ${events.joinToString(", ") { it.toJson() }} ]
}
"""
val mediaType = "application/json; charset=utf-8".toMediaType()
val body = jsonObject.toRequestBody(mediaType)
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url("https://push.amethyst.social/register")
.post(body)
.build()
val client = HttpClient.getHttpClient()
client.newCall(request).execute()
} catch (e: java.lang.Exception) {
Log.e("FirebaseMsgService", "Unable to register with push server", e)
}
}
fun go(notificationToken: String) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
postRegistrationEvent(
signEventsToProveControlOfAccounts(accounts, notificationToken)
)
}
}
}

View File

@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
callback?.onFailed(throwable)
}
scope.launch(exceptionHandler) {
fetch(timeOut)
}
}
private suspend fun fetch(timeOut: Int = 30000) {
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(document)
urlInfoItem.url = url
}
callback?.onComplete(urlInfoItem)
}
private fun checkIsImageUrl(): Boolean {
val uri = Uri.parse(url)
var isImage = false
for (imageExtension in imageExtensionArray) {
if (uri.path != null && uri.path!!.toLowerCase(Locale.getDefault()).endsWith(imageExtension)) {
isImage = true
break
}
}
return isImage
}
fun cleanUp() {
scope.cancel()
callback = null
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.vitorpamplona.amethyst.service.previews
data class UrlInfoItem(
var url: String = "",
var title: String = "",
var description: String = "",
var image: String = ""
) {
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
}

View File

@@ -0,0 +1,116 @@
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.service.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
/* for <meta property="og:" to get title */
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
/* for <meta property="og:" to get description */
private val META_OG_DESCRIPTION =
arrayOf("og:description", "\"og:description\"", "'og:description'")
/* for <meta property="og:" to get image */
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
/*for <meta name=... to get title */
private val META_NAME_TITLE = arrayOf(
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'"
)
/*for <meta name=... to get description */
private val META_NAME_DESCRIPTION = arrayOf(
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'"
)
/*for <meta name=... to get image */
private val META_NAME_IMAGE = arrayOf(
"twitter:image",
"\"twitter:image\"",
"'twitter:image'"
)
/*for <meta itemprop=... to get title */
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
/*for <meta itemprop=... to get description */
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
/*for <meta itemprop=... to get image */
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
private const val CONTENT = "content"
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
return@withContext Jsoup.connect(url)
.proxy(HttpClient.getProxy())
.timeout(timeOut)
.get()
}
suspend fun parseHtml(document: Document): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
val urlInfo = UrlInfoItem()
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (urlInfo.title.isEmpty()) {
urlInfo.title =
it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(
CONTENT
)
}
in META_ITEMPROP_IMAGE -> if (urlInfo.image.isEmpty()) {
urlInfo.image = it.attr(
CONTENT
)
}
}
if (urlInfo.allFetchComplete()) {
return@withContext urlInfo
}
}
return@withContext urlInfo
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -69,7 +70,7 @@ object Client : RelayPool.Listener {
if (relay == null) {
RelayPool.send(signedEvent)
} else {
val useConnectedRelayIfPresent = relays.filter { it.url == relay }
val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach {
@@ -91,7 +92,7 @@ object Client : RelayPool.Listener {
@OptIn(DelicateCoroutinesApi::class)
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
val relay = Relay(url, true, true, feedTypes ?: emptySet())
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
RelayPool.addRelay(relay)
relay.requestAndWatch {
@@ -102,7 +103,7 @@ object Client : RelayPool.Listener {
onConnected(relay)
GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply
delay(60000) // waits for a reply
relay.disconnect()
RelayPool.removeRelay(relay)

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.HttpClient
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -10,7 +11,7 @@ object Constants {
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
}.toTypedArray()
}

View File

@@ -19,13 +19,28 @@ class EOSERelayList(var relayList: Map<String, EOSETime> = emptyMap()) {
}
}
class EOSEAccount(var users: Map<User, EOSERelayList> = emptyMap()) {
fun addOrUpdate(user: User, relayUrl: String, time: Long) {
val relayList = users[user]
class EOSEFollowList(var followList: Map<String, EOSERelayList> = emptyMap()) {
fun addOrUpdate(listCode: String, relayUrl: String, time: Long) {
val relayList = followList[listCode]
if (relayList == null) {
users = users + mapOf(user to EOSERelayList(mapOf(relayUrl to EOSETime(time))))
val newList = EOSERelayList()
newList.addOrUpdate(relayUrl, time)
followList = followList + mapOf(listCode to newList)
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
}
class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
fun addOrUpdate(user: User, listCode: String, relayUrl: String, time: Long) {
val followList = users[user]
if (followList == null) {
val newList = EOSEFollowList()
newList.addOrUpdate(listCode, relayUrl, time)
users = users + mapOf(user to newList)
} else {
followList.addOrUpdate(listCode, relayUrl, time)
}
}
}

View File

@@ -11,6 +11,8 @@ import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.Date
enum class FeedType {
@@ -23,9 +25,17 @@ class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet()
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
proxy: Proxy?
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)
.connectTimeout(duration)
.writeTimeout(duration)
.followRedirects(true)
.followSslRedirects(true)
.build()
@@ -205,7 +215,7 @@ class Relay(
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(12).joinToString(",") { it.filter.toJson(url) }}]"""
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()

View File

@@ -30,6 +30,10 @@ object RelayPool : Relay.Listener {
return relays.firstOrNull() { it.url == url }
}
fun getRelays(url: String): List<Relay> {
return relays.filter { it.url == url }
}
fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) }

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui
import android.content.Context
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
@@ -12,16 +11,17 @@ import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.Coil
import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -38,39 +38,13 @@ class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val nip19 = Nip19.uriToRoute(intent?.data?.toString())
val startingPage = when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> "Event/${nip19.hex}"
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
} ?: try {
intent?.data?.toString()?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
val uri = intent?.data?.toString()
val startingPage = uriToRoute(uri)
// Initializes video cache.
VideoCache.init(this.applicationContext)
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.respectCacheHeaders(false)
.build()
}
LocalPreferences.migrateSingleUserPrefs()
setContent {
@@ -78,7 +52,7 @@ class MainActivity : FragmentActivity() {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel()
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, startingPage)
@@ -92,11 +66,15 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
// starts muted every time
DefaultMutedSetting.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
}
override fun onPause() {
@@ -125,3 +103,39 @@ class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
}
}
}
fun uriToRoute(uri: String?): String? {
return if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} else {
if (uri?.startsWith("nostr:Hashtag?id=") == true) {
Route.Hashtag.route.replace("{id}", uri.removePrefix("nostr:Hashtag?id="))
} else {
val nip19 = Nip19.uriToRoute(uri)
when (nip19?.type) {
Nip19.Type.USER -> "User/${nip19.hex}"
Nip19.Type.NOTE -> "Note/${nip19.hex}"
Nip19.Type.EVENT -> {
if (nip19.kind == PrivateDmEvent.kind) {
"Room/${nip19.author}"
} else if (nip19.kind == ChannelMessageEvent.kind || nip19.kind == ChannelCreateEvent.kind || nip19.kind == ChannelMetadataEvent.kind) {
"Channel/${nip19.hex}"
} else {
"Event/${nip19.hex}"
}
}
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
else -> null
}
} ?: try {
uri?.let {
Nip47.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,47 @@
package com.vitorpamplona.amethyst.ui.actions
import kotlinx.coroutines.delay
import java.net.HttpURLConnection
import java.net.URL
class ImageDownloader {
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
var imageData: ByteArray? = null
var tentatives = 0
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) {
imageData = try {
HttpURLConnection.setFollowRedirects(true)
var url = URL(imageUrl)
var huc = url.openConnection() as HttpURLConnection
huc.instanceFollowRedirects = true
var responseCode = huc.responseCode
if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again
url = URL(newUrl)
huc = url.openConnection() as HttpURLConnection
responseCode = huc.responseCode
}
if (responseCode in 200..300) {
huc.inputStream.use { it.readBytes() }
} else {
tentatives++
delay(1000)
null
}
} catch (e: Exception) {
tentatives++
delay(1000)
null
}
}
return imageData
}
}

View File

@@ -10,6 +10,7 @@ import android.provider.MediaStore
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okio.BufferedSource
import okio.IOException
@@ -32,7 +33,7 @@ object ImageSaver {
onSuccess: () -> Any?,
onError: (Throwable) -> Any?
) {
val client = OkHttpClient.Builder().build()
val client = HttpClient.getHttpClient()
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
@@ -79,7 +80,7 @@ object ImageSaver {
}
fun saveImage(
byteArray: ByteArray,
localFile: File,
mimeType: String?,
context: Context,
onSuccess: () -> Any?,
@@ -87,7 +88,7 @@ object ImageSaver {
) {
try {
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val buffer = byteArray.inputStream().source().buffer()
val buffer = localFile.inputStream().source().buffer()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveContentQ(
@@ -126,8 +127,14 @@ object ImageSaver {
)
}
val masterUri = if (contentType.startsWith("image")) {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}

View File

@@ -2,16 +2,17 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.io.InputStream
import java.util.*
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
@@ -29,42 +30,61 @@ object ImageUploader {
val contentType = contentResolver.getType(uri)
val imageInputStream = contentResolver.openInputStream(uri)
val length = contentResolver.query(uri, null, null, null, null)?.use {
it.moveToFirst()
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
it.getLong(sizeIndex)
} ?: 0
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = if (server == ServersAvailable.IMGUR) {
ImgurServer()
} else if (server == ServersAvailable.NOSTR_IMG) {
NostrImgServer()
} else {
ImgurServer()
val myServer = when (server) {
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
NostrBuildServer()
}
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
NostrFilesDevServer()
}
else -> {
NostrBuildServer()
}
}
uploadImage(imageInputStream, contentType, myServer, onSuccess, onError)
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
length: Long,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = OkHttpClient.Builder().build()
val client = HttpClient.getHttpClient()
val requestBody: RequestBody
val requestBuilder = Request.Builder()
val requestBody: RequestBody = MultipartBody.Builder()
requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
server.inputParameterName(contentType),
"$fileName.$extension",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length
override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll)
@@ -73,17 +93,14 @@ object ImageUploader {
)
.build()
val requestBuilder = Request.Builder()
server.clientID()?.let {
requestBuilder.header("Authorization", it)
requestBuilder.addHeader("Authorization", it)
}
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(server.postUrl(contentType))
.post(requestBody)
val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
@@ -91,7 +108,7 @@ object ImageUploader {
try {
check(response.isSuccessful)
response.body.use { body ->
val url = server.parseUrlFromSucess(body.string())
val url = server.parseUrlFromSuccess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
@@ -114,7 +131,8 @@ object ImageUploader {
abstract class FileServer {
abstract fun postUrl(contentType: String?): String
abstract fun parseUrlFromSucess(body: String): String?
abstract fun parseUrlFromSuccess(body: String): String?
abstract fun inputParameterName(contentType: String?): String
open fun clientID(): String? = null
}
@@ -122,12 +140,16 @@ abstract class FileServer {
class NostrImgServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = null
}
@@ -137,22 +159,42 @@ class ImgurServer : FileServer() {
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
}
override fun parseUrlFromSucess(body: String): String? {
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
}
override fun inputParameterName(contentType: String?): String {
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
}
override fun clientID() = "Client-ID e6aea87296f3f96"
}
class NostrBuildServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php"
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
override fun parseUrlFromSuccess(body: String): String? {
val url = jacksonObjectMapper().readTree(body) // return url.toString()
return url.toString().replace("\"", "")
}
override fun parseUrlFromSucess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
val url = tree?.get("data")?.get("link")?.asText()
return url
override fun inputParameterName(contentType: String?): String {
return "fileToUpload"
}
override fun clientID() = null
}
class NostrFilesDevServer : FileServer() {
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
override fun parseUrlFromSuccess(body: String): String? {
val tree = jacksonObjectMapper().readTree(body)
return tree?.get("url")?.asText()
}
override fun inputParameterName(contentType: String?): String {
return "file"
}
override fun clientID() = null

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