Compare commits

...

182 Commits

Author SHA1 Message Date
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
Pextar
fc1ee54548 Merge branch 'vitorpamplona:main' into main 2023-05-14 08:29:09 +02:00
Pextar
754ac9894b Delete values-sv 2023-05-12 20:38:08 +02:00
Pextar
e80c8b37b3 Create values-sv 2023-05-12 12:14:20 +02:00
178 changed files with 6308 additions and 2174 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

@@ -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)
@@ -54,13 +55,18 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [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
@@ -69,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

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 158
versionName "0.46.4"
versionCode 186
versionName "0.53.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -24,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"
@@ -71,7 +72,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
kotlinCompilerExtensionVersion "1.4.7"
}
packagingOptions {
@@ -82,12 +83,11 @@ android {
lint {
disable 'MissingTranslation'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.activity:activity-compose:1.7.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"
@@ -128,17 +128,18 @@ 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-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.6'
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -177,8 +178,17 @@ dependencies {
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'

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

@@ -35,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">

View File

@@ -58,6 +58,7 @@ private object PrefKeys {
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" }
}
@@ -214,6 +215,12 @@ object LocalPreferences {
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()
}
@@ -251,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 {
@@ -292,6 +299,12 @@ object LocalPreferences {
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?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels,
@@ -311,7 +324,8 @@ object LocalPreferences {
hideBlockAlertDialog,
latestContactList,
proxy,
proxyPort
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) {

View File

@@ -15,7 +15,6 @@ 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
@@ -46,9 +45,6 @@ object ServiceManager {
NostrSingleEventDataSource.start()
NostrSingleChannelDataSource.start()
NostrSingleUserDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect(Constants.convertDefaultRelays())
}
}

View File

@@ -2,6 +2,8 @@ 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
@@ -43,6 +45,7 @@ val GLOBAL_FOLLOWS = " Global "
val KIND3_FOLLOWS = " All Follows "
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
@@ -53,7 +56,7 @@ 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,
@@ -62,7 +65,8 @@ class Account(
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null,
var proxy: Proxy?,
var proxyPort: Int
var proxyPort: Int,
var showSensitiveContent: Boolean? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -217,7 +221,12 @@ class Account(
zapPaymentRequest?.let { nip47 ->
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, zappedNote) {
@@ -472,7 +481,14 @@ class Account(
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: 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 }
@@ -486,6 +502,7 @@ class Account(
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!
)
@@ -502,7 +519,8 @@ class Account(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String? = null
zapReceiver: String? = null,
wantsToMarkAsSensitive: Boolean
) {
if (!isWriteable()) return
@@ -521,14 +539,15 @@ class Account(
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver
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>?, zapReceiver: String? = null) {
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 }
@@ -541,13 +560,14 @@ class Account(
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>?, zapReceiver: String? = null) {
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
@@ -561,6 +581,7 @@ class Account(
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
markAsSensitive = wantsToMarkAsSensitive,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -593,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)
@@ -615,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)
@@ -637,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)
@@ -665,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)
@@ -828,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.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -1046,6 +1119,12 @@ class Account(
saveable.invalidateData()
}
fun updateShowSensitiveContent(show: Boolean?) {
showSensitiveContent = show
saveable.invalidateData()
live.invalidateData()
}
fun registerObservers() {
// Observes relays to restart connections
userProfile().live().relays.observeForever {
@@ -1067,6 +1146,7 @@ class Account(
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
}
}
@@ -1101,4 +1181,5 @@ class AccountLiveData(private val account: Account) : LiveData<AccountState>(Acc
}
}
@Immutable
class AccountState(val account: Account)

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()

View File

@@ -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,11 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
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
@@ -281,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)
@@ -529,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)
}
}
@@ -554,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?) {
@@ -739,19 +768,18 @@ object LocalCache {
note.addRelay(relay)
}
val file = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
if (!file.exists()) {
try {
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
cachePath.mkdirs()
val stream = FileOutputStream(File(cachePath, event.id))
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.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
} catch (e: IOException) {
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
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.
@@ -811,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) ||
@@ -819,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) ||
@@ -834,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) ||
@@ -920,6 +974,7 @@ object LocalCache {
try {
when (event) {
is AudioTrackEvent -> consume(event)
is BadgeAwardEvent -> consume(event)
is BadgeDefinitionEvent -> consume(event)
is BadgeProfilesEvent -> consume(event)
@@ -948,6 +1003,7 @@ object LocalCache {
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)

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.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
@@ -20,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()
@@ -28,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.
@@ -228,7 +232,7 @@ 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) {
@@ -241,11 +245,11 @@ open class Note(val idHex: String) {
}
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> {
@@ -439,4 +443,5 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
}
}
@Immutable
class NoteState(val note: 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
@@ -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
@@ -417,4 +421,5 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
}
}
@Immutable
class UserState(val user: User)

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

@@ -75,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

@@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
BadgeAwardEvent.kind
),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400,
limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
)
)

View File

@@ -1,8 +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
@@ -13,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, HighlightEvent.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,8 +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
@@ -56,7 +58,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
@@ -72,7 +74,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.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())

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,13 +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
@@ -19,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
}

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
@@ -80,7 +80,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind,
LnZapRequestEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind
HighlightEvent.kind,
AudioTrackEvent.kind,
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -119,7 +121,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -28,7 +28,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)

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,6 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.nip19.Tlv
@@ -9,6 +10,7 @@ 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"

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,

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,9 +11,9 @@ class BadgeDefinitionEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
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.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)

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,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.tagSearch
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex
@Immutable
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
@@ -19,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 }
@@ -61,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()) {
@@ -102,6 +107,7 @@ open class BaseTextNoteEvent(
}
}
citedNotesCache = citations
return citations
}

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 BookmarkListEvent(
id: HexKey,
pubKey: HexKey,

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, zapReceiver: String?, 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(
@@ -35,6 +46,9 @@ class ChannelMessageEvent(
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,6 +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
@@ -14,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,
@@ -46,6 +48,12 @@ open class Event(
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 {
@@ -220,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)
@@ -243,6 +252,7 @@ open class Event(
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)
@@ -282,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
@@ -35,4 +37,5 @@ interface EventInterface {
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,

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,

View File

@@ -1,11 +1,13 @@
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,
@@ -14,9 +16,9 @@ abstract class GeneralListEvent(
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 category() = dTag()
fun bookmarkedPosts() = taggedEvents()

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 HighlightEvent(
id: HexKey,
pubKey: HexKey,

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,

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,6 +1,7 @@
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
@@ -12,6 +13,7 @@ import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
@Immutable
class LnZapPaymentRequestEvent(
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.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.model.HexKey
import nostr.postr.Utils
import java.lang.reflect.Type
@Immutable
class LnZapPaymentResponseEvent(
id: HexKey,
pubKey: HexKey,

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,
@@ -71,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) {

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,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.hexToByteArray
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
id: HexKey,
pubKey: HexKey,
@@ -15,9 +17,9 @@ 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 {

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 PeopleListEvent(
id: HexKey,
pubKey: HexKey,

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,
@@ -49,7 +51,8 @@ class PollNoteEvent(
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?,
zapReceiver: String?
zapReceiver: String?,
markAsSensitive: Boolean
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
@@ -73,6 +76,9 @@ class PollNoteEvent(
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)

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,
@@ -27,6 +29,10 @@ class PrivateDmEvent(
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.
*
@@ -37,7 +43,7 @@ class PrivateDmEvent(
fun with(pubkeyHex: String): Boolean {
return pubkeyHex == pubKey ||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
tags.any { it.size > 1 && it[0] == "p" && it[1] == pubkeyHex }
}
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
@@ -71,7 +77,8 @@ class PrivateDmEvent(
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,
@@ -92,6 +99,9 @@ class PrivateDmEvent(
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,
@@ -27,6 +29,7 @@ class TextNoteEvent(
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: String?,
markAsSensitive: Boolean,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -43,6 +46,7 @@ class TextNoteEvent(
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
tags.add(listOf("t", it.lowercase()))
}
extraTags?.forEach {
tags.add(listOf("t", it))
@@ -53,6 +57,9 @@ class TextNoteEvent(
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)

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

@@ -70,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 {
@@ -103,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

@@ -30,6 +30,7 @@ class Relay(
) {
val seconds = if (proxy != null) 20L else 10L
val duration = Duration.ofSeconds(seconds)
private val httpClient = OkHttpClient.Builder()
.proxy(proxy)
.readTimeout(duration)

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

@@ -20,6 +20,7 @@ import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -27,7 +28,7 @@ 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.muted
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
@@ -46,35 +47,7 @@ class MainActivity : FragmentActivity() {
val uri = intent?.data?.toString()
val startingPage = if (uri.equals("nostr:Notifications", true)) {
Route.Notification.route.replace("{scrollToTop}", "true")
} 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
}
val startingPage = uriToRoute(uri)
// Initializes video cache.
VideoCache.init(this.applicationContext)
@@ -88,6 +61,7 @@ class MainActivity : FragmentActivity() {
}
add(SvgDecoder.Factory())
} // .logger(DebugLogger())
.okHttpClient { HttpClient.getHttpClient() }
.respectCacheHeaders(false)
.build()
}
@@ -114,7 +88,7 @@ class MainActivity : FragmentActivity() {
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
DefaultMutedSetting.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
@@ -150,3 +124,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

@@ -40,9 +40,9 @@ object ImageUploader {
"Can't open the image input stream"
}
val myServer = when (server) {
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
ImgurServer()
}
// ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
// ImgurServer()
// }
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
NostrImgServer()
}
@@ -53,7 +53,7 @@ object ImageUploader {
NostrFilesDevServer()
}
else -> {
ImgurServer()
NostrBuildServer()
}
}

View File

@@ -51,7 +51,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -73,8 +72,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = account
Dialog(
onDismissRequest = {
@@ -115,7 +115,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, account, navController)
RenderSeach(searchBarViewModel, account, nav)
}
}
}
@@ -126,7 +126,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController:
private fun RenderSeach(
searchBarViewModel: SearchBarViewModel,
account: Account,
navController: NavController
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -274,7 +274,7 @@ private fun RenderSeach(
UserComposeForChat(
item,
account = account,
navController = navController
nav = nav
)
}
@@ -294,7 +294,7 @@ private fun RenderSeach(
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") }
onClick = { nav("Channel/${item.idHex}") }
)
}
}
@@ -306,12 +306,12 @@ private fun RenderSeach(
fun UserComposeForChat(
baseUser: User,
account: Account,
navController: NavController
nav: (String) -> Unit
) {
Column(
modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
onClick = { nav("Room/${baseUser.pubkeyHex}") }
)
) {
Row(
@@ -323,7 +323,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
UserPicture(baseUser, nav, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@@ -38,9 +38,10 @@ open class NewMediaModel : ViewModel() {
this.mediaType = contentType
this.selectedServer = defaultServer()
if (selectedServer == ServersAvailable.IMGUR) {
selectedServer = ServersAvailable.IMGUR_NIP_94
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
// if (selectedServer == ServersAvailable.IMGUR) {
// selectedServer = ServersAvailable.IMGUR_NIP_94
// } else
if (selectedServer == ServersAvailable.NOSTRIMG) {
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94

View File

@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -39,7 +38,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account = accountViewModel.accountLiveData.value?.account ?: return
val resolver = LocalContext.current.contentResolver
val context = LocalContext.current
@@ -111,7 +110,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
// selectedServer == ServersAvailable.IMGUR_NIP_94 ||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
}
@@ -121,7 +120,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
val scope = rememberCoroutineScope()
val fileServers = listOf(
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),

View File

@@ -22,9 +22,14 @@ import androidx.compose.material.icons.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -56,12 +61,11 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
@@ -73,7 +77,10 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
@@ -82,7 +89,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
@@ -95,6 +102,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
}
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
@@ -146,7 +159,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scroolState)
.verticalScroll(scrollState)
) {
Notifying(postViewModel.mentions) {
postViewModel.removeFromReplyList(it)
@@ -284,7 +297,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
true,
MaterialTheme.colors.background,
accountViewModel,
navController
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
@@ -328,10 +341,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
if (postViewModel.canUsePoll) {
val hashtag = stringResource(R.string.poll_hashtag)
// These should be hashtag recommendations the user selects in the future.
// val hashtag = stringResource(R.string.poll_hashtag)
// postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
AddPollButton(postViewModel.wantsPoll) {
postViewModel.wantsPoll = !postViewModel.wantsPoll
postViewModel.includePollHashtagInMessage(postViewModel.wantsPoll, hashtag)
}
}
@@ -341,6 +355,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
@@ -380,8 +398,9 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
onClick(myUser)
}
) {
Text(
"${myUser.toBestDisplayName()}",
CreateTextWithEmoji(
text = "${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
color = Color.White,
textAlign = TextAlign.Center
)
@@ -529,6 +548,60 @@ private fun ForwardZapTo(
}
}
@Composable
private fun MarkAsSensitive(
postViewModel: NewPostViewModel,
onClick: () -> Unit
) {
IconButton(
onClick = {
onClick()
}
) {
Box(
Modifier
.height(20.dp)
.width(23.dp)
) {
if (!postViewModel.wantsToMarkAsSensitive) {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.onBackground
)
} else {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier
.size(18.dp)
.align(Alignment.BottomStart),
tint = Color.Red
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(id = R.string.content_warning),
modifier = Modifier
.size(10.dp)
.align(Alignment.TopEnd),
tint = Color.Yellow
)
}
}
}
}
@Composable
fun CloseButton(onCancel: () -> Unit) {
Button(
@@ -632,11 +705,12 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
enum class ServersAvailable {
IMGUR,
// IMGUR,
NOSTR_BUILD,
NOSTRIMG,
NOSTRFILES_DEV,
IMGUR_NIP_94,
// IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NOSTR_BUILD_NIP_94,
NOSTRFILES_DEV_NIP_94,
@@ -659,11 +733,11 @@ fun ImageVideoDescription(
val isVideo = mediaType.startsWith("video")
val fileServers = listOf(
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
// Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),

View File

@@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -66,6 +67,9 @@ open class NewPostViewModel : ViewModel() {
var forwardZapTo by mutableStateOf<User?>(null)
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -96,6 +100,7 @@ open class NewPostViewModel : ViewModel() {
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
@@ -117,13 +122,13 @@ open class NewPostViewModel : ViewModel() {
}
if (wantsPoll) {
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.channel() != null) {
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else if (originalNote?.event is PrivateDmEvent) {
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
} else {
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
}
cancel()
@@ -182,12 +187,15 @@ open class NewPostViewModel : ViewModel() {
wantsInvoice = false
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? {
@@ -211,8 +219,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}
@@ -225,8 +235,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
} else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList()
}
}

View File

@@ -15,14 +15,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
@Composable
fun ChannelFabColumn(account: Account, navController: NavController) {
fun ChannelFabColumn(account: Account, nav: (String) -> Unit) {
var isOpen by remember {
mutableStateOf(false)
}
@@ -40,7 +39,7 @@ fun ChannelFabColumn(account: Account, navController: NavController) {
}
if (wantsToJoinChannelOrUser) {
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, nav = nav)
}
Column() {

View File

@@ -16,20 +16,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) {
fun NewNoteButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController)
NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -14,6 +15,7 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import java.util.Base64
@Immutable
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null

View File

@@ -22,9 +22,11 @@ class BundledUpdate(
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
fun invalidate() {
fun invalidate(ignoreIfDoing: Boolean = false) {
if (onlyOneInBlock.getAndSet(true)) {
invalidatesAgain = true
if (!ignoreIfDoing) {
invalidatesAgain = true
}
return
}

View File

@@ -5,18 +5,17 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable
fun ClickableNoteTag(
baseNote: Note,
navController: NavController
nav: (String) -> Unit
) {
ClickableText(
text = AnnotatedString("@${baseNote.idNote().toShortenHex()}"),
onClick = { navController.navigate("Note/${baseNote.idHex}") },
onClick = { nav("Note/${baseNote.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -1,6 +1,14 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -11,12 +19,29 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -26,16 +51,16 @@ import kotlinx.coroutines.withContext
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
if (nip19.type == Nip19.Type.USER) {
DisplayUser(nip19, navController)
DisplayUser(nip19, nav)
} else if (nip19.type == Nip19.Type.ADDRESS) {
DisplayAddress(nip19, navController)
DisplayAddress(nip19, nav)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, navController)
DisplayNote(nip19, nav)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, navController)
DisplayEvent(nip19, nav)
} else {
Text(
"@${nip19.hex}${nip19.additionalChars} "
@@ -46,9 +71,9 @@ fun ClickableRoute(
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -58,36 +83,36 @@ private fun DisplayEvent(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
val note = remember(noteState) { noteState?.note } ?: return
val channel = remember(noteState) { note.channel() }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
clickablePart = channel.toBestDisplayName(),
suffix = "${nip19.additionalChars} ",
route = "Channel/${channel.idHex}",
nav = nav
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Event/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
nav = nav
)
}
}
@@ -102,9 +127,9 @@ private fun DisplayEvent(
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -114,36 +139,36 @@ private fun DisplayNote(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val note = remember(noteState) { noteState?.note } ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Channel/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Room/${note.author?.pubkeyHex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Room/${note.author?.pubkeyHex}",
nav = nav
)
} else if (channel != null) {
CreateClickableText(
channel.toBestDisplayName(),
nip19.additionalChars,
"Channel/${note.channel()?.idHex}",
navController
clickablePart = channel.toBestDisplayName(),
suffix = "${nip19.additionalChars} ",
route = "Channel/${note.channel()?.idHex}",
nav = nav
)
} else {
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
nav = nav
)
}
}
@@ -158,9 +183,9 @@ private fun DisplayNote(
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -170,13 +195,13 @@ private fun DisplayAddress(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = noteState?.note ?: return
val note = remember(noteState) { noteState?.note } ?: return
CreateClickableText(
note.idDisplayNote(),
nip19.additionalChars,
"Note/${nip19.hex}",
navController
clickablePart = "@${note.idDisplayNote()}",
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
nav = nav
)
}
@@ -190,9 +215,9 @@ private fun DisplayAddress(
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
navController: NavController
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(null) }
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
@@ -202,14 +227,19 @@ private fun DisplayUser(
userBase?.let {
val userState by it.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
CreateClickableText(
user.toBestDisplayName(),
nip19.additionalChars,
"User/${nip19.hex}",
navController
)
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
clickablePart = userDisplayName,
suffix = "${nip19.additionalChars} ",
tags = userTags,
route = route,
nav = nav
)
}
}
if (userBase == null) {
@@ -220,20 +250,281 @@ private fun DisplayUser(
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
fun CreateClickableText(
clickablePart: String,
suffix: String,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
nav: (String) -> Unit
) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()
) {
append("@$clickablePart")
append(clickablePart)
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()
) {
append("$suffix ")
append(suffix)
}
},
onClick = { navController.navigate(route) }
onClick = { nav(route) }
)
}
@Composable
fun CreateTextWithEmoji(
text: String,
tags: List<List<String>>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val emojis = remember {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
}
@Composable
fun CreateTextWithEmoji(
text: String,
emojis: Map<String, String>,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val textColor = color.takeOrElse {
LocalTextStyle.current.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
}
}
if (emojis.isEmpty()) {
Text(
text = text,
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
} else {
val myList = remember {
assembleAnnotatedList(text, emojis)
}
val style = LocalTextStyle.current.merge(
TextStyle(
color = textColor,
textAlign = textAlign,
fontWeight = fontWeight,
fontSize = fontSize
)
).toSpanStyle()
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: List<List<String>>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
ClickableText(
AnnotatedString(clickablePart),
style = style,
onClick = onClick
)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
onClick(it)
}
}
}
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: List<List<String>>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
nav: (String) -> Unit
) {
val emojis = remember(tags) {
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
}
if (emojis.isEmpty()) {
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav)
} else {
val myList = remember {
assembleAnnotatedList(clickablePart, emojis)
}
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
nav(route)
}
val myList2 = remember {
assembleAnnotatedList(suffix, emojis)
}
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
}
}
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
return NIP30Parser().buildArray(text).map {
val url = emojis[it]
if (url != null) {
ImageUrlType(url)
} else {
TextType(it)
}
}
}
open class Renderable()
class TextType(val text: String) : Renderable()
class ImageUrlType(val url: String) : Renderable()
@Composable
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = annotatedText,
modifier = pressIndicator,
inlineContent = inlineContent,
onTextLayout = {
layoutResult.value = it
}
)
}
@Composable
fun InLineIconRenderer(
wordsInOrder: List<Renderable>,
style: SpanStyle,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
) {
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
if (value is ImageUrlType) {
Pair(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp)
)
}
)
} else {
null
}
}.associate { it.first to it.second }
val annotatedText = buildAnnotatedString {
wordsInOrder.forEachIndexed { idx, value ->
withStyle(
style
) {
if (value is TextType) {
append(value.text)
} else if (value is ImageUrlType) {
appendInlineContent("inlineContent$idx", "[icon]")
}
}
}
}
Text(
text = annotatedText,
inlineContent = inlineContent,
maxLines = maxLines,
overflow = overflow,
modifier = modifier
)
}

View File

@@ -7,18 +7,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.User
@Composable
fun ClickableUserTag(
user: User,
navController: NavController
nav: (String) -> Unit
) {
val innerUserState by user.live().metadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -24,7 +25,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -38,7 +38,7 @@ fun ExpandableRichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
var showFullText by remember { mutableStateOf(false) }
@@ -50,10 +50,14 @@ fun ExpandableRichTextViewer(
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
}
val text = if (showFullText) {
content
} else {
content.take(whereToCut)
val text by remember(content) {
derivedStateOf {
if (showFullText) {
content
} else {
content.take(whereToCut)
}
}
}
Box {
@@ -64,7 +68,7 @@ fun ExpandableRichTextViewer(
tags,
backgroundColor,
accountViewModel,
navController
nav
)
if (content.length > whereToCut && !showFullText) {

View File

@@ -19,14 +19,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
@@ -43,6 +42,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -50,7 +50,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -82,7 +81,8 @@ fun isMarkdown(content: String): Boolean {
content.startsWith("# ") ||
content.contains("##") ||
content.contains("__") ||
content.contains("```")
content.contains("```") ||
content.contains("](")
}
@Composable
@@ -93,27 +93,28 @@ fun RichTextViewer(
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
val isMarkdown = remember { isMarkdown(content) }
val isMarkdown = remember(content) { isMarkdown(content) }
Column(modifier = modifier) {
if (isMarkdown) {
RenderContentAsMarkdown(content, backgroundColor)
RenderContentAsMarkdown(content, backgroundColor, tags, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
}
}
}
@Stable
class RichTextViewerState(
val content: String,
val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
val imageList: List<ZoomableUrlContent>,
val customEmoji: Map<String, String>
)
@OptIn(ExperimentalTime::class)
@Composable
private fun RenderRegular(
content: String,
@@ -121,10 +122,10 @@ private fun RenderRegular(
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
nav: (String) -> Unit
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
var state by remember(content) {
mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
val scope = rememberCoroutineScope()
@@ -145,79 +146,76 @@ private fun RenderRegular(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
if (urlSet.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
state = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
}
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
processedState?.let { state ->
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
@@ -225,69 +223,76 @@ private fun RenderRegular(
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
}
@@ -296,7 +301,15 @@ private fun RenderRegular(
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
CreateTextWithEmoji(
text = "$word ",
emojis = customEmoji
)
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List<List<String>>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
@@ -320,7 +333,6 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
),
stringStyle = richTextDefaults.stringStyle?.copy(
linkStyle = SpanStyle(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
),
codeStyle = SpanStyle(
@@ -332,20 +344,20 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
)
)
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember { mutableStateOf(0) }
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(null) }
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember(content) { mutableStateOf(0) }
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
nip19References = returnNIP19References(content, tags)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
}
}
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
@@ -353,8 +365,8 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
var baseUser by remember(it) { mutableStateOf<User?>(null) }
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
@@ -375,17 +387,23 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
refresh++
LaunchedEffect(key1 = noteState) {
refresh++
}
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
refresh++
LaunchedEffect(key1 = userState) {
refresh++
}
}
}
}
val uri = LocalUriHandler.current
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
@@ -393,26 +411,70 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
)
) { link ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
}
}
}
}
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
if (nip19.type == Nip19.Type.USER) {
return LocalCache.users[nip19.hex]?.bestDisplayName()
} else if (nip19.type == Nip19.Type.NOTE) {
return LocalCache.notes[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.ADDRESS) {
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else if (nip19.type == Nip19.Type.EVENT) {
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
} else {
return null
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $tag", e)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
}
}
return null
}
private fun returnNIP19References(content: String): List<Nip19.Return> {
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
if (nip19.type == Nip19.Type.USER) {
LocalCache.users[nip19.hex]?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (nip19.type == Nip19.Type.NOTE) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.ADDRESS) {
LocalCache.addressables[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.EVENT) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
return null
}
private fun returnNIP19References(content: String, tags: List<List<String>>?): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -424,10 +486,21 @@ private fun returnNIP19References(content: String): List<Nip19.Return> {
}
}
}
tags?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
} else if (it[0] == "a" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
}
}
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String): String {
private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<String>>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -440,15 +513,43 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
} else if (isBechLink(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val displayName = getDisplayNameFromNip19(parsedNip19)
if (displayName != null) {
"[@$displayName](nostr://$word) "
val pair = getDisplayNameFromNip19(parsedNip19)
if (pair != null) {
val (displayName, nip19) = pair
"[$displayName](nostr:$nip19) "
} else {
"$word "
}
} else {
"$word "
}
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
val pair = getDisplayNameAndNIP19FromTag(word, tags)
if (pair != null) {
returnContent += "[${pair.first}](nostr:${pair.second}) "
} else {
returnContent += "$word "
}
} else if (hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
@@ -469,12 +570,14 @@ fun isBechLink(word: String): Boolean {
}
@Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
@@ -503,7 +606,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
nav = nav
)
if (!it.second.isNullOrEmpty()) {
Text(
@@ -511,21 +614,23 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
)
}
} ?: nip19Route?.let {
ClickableRoute(it, navController)
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
} else {
nip19Route?.let {
ClickableRoute(it, navController)
ClickableRoute(it, nav)
} ?: Text(text = "$word ")
}
}
@Composable
fun HashTag(word: String, navController: NavController) {
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
@@ -543,7 +648,7 @@ fun HashTag(word: String, navController: NavController) {
}
tagSuffixPair?.let { tagPair ->
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) }
ClickableText(
text = buildAnnotatedString {
withStyle(
@@ -552,7 +657,7 @@ fun HashTag(word: String, navController: NavController) {
append("#${tagPair.first}")
}
},
onClick = { navController.navigate("Hashtag/${tagPair.first}") }
onClick = { nav("Hashtag/${tagPair.first}") }
)
if (hashtagIcon != null) {
@@ -598,12 +703,14 @@ fun HashTag(word: String, navController: NavController) {
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
@@ -632,8 +739,24 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
baseUserPair?.let {
ClickableUserTag(it.first, navController)
Text(text = "${it.second} ")
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = "${it.second} ",
tags = userTags,
route = route,
nav = nav
)
}
baseNotePair?.let {
@@ -652,13 +775,13 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
navController = navController
nav = nav
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
}
} else {
ClickableNoteTag(it.first, navController)
ClickableNoteTag(it.first, nav)
Text(text = "${it.second} ")
}
}

View File

@@ -16,6 +16,7 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
import coil.size.Size
import java.util.Date
@Composable
fun RobohashAsyncImage(
@@ -33,10 +34,12 @@ fun RobohashAsyncImage(
) {
val context = LocalContext.current
val size = with(LocalDensity.current) {
robotSize.roundToPx()
remember {
robotSize.roundToPx()
}
}
val imageRequest = remember(size, robot) {
val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest(
context,
robot,
@@ -58,7 +61,7 @@ fun RobohashAsyncImage(
)
}
var imageErrors = setOf<String>()
var imageErrors = mapOf<String, Long>()
@Composable
fun RobohashFallbackAsyncImage(
@@ -73,7 +76,9 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
if (imageErrors.contains(model)) {
val errorCache = remember(imageErrors) { imageErrors[model] }
if (errorCache != null && (Date().time / 1000) - errorCache < (60 * 5)) {
RobohashAsyncImage(
robot = robot,
robotSize = robotSize,
@@ -110,7 +115,7 @@ fun RobohashFallbackAsyncImage(
colorFilter = colorFilter,
filterQuality = filterQuality,
onError = {
imageErrors = imageErrors + model
imageErrors = imageErrors + Pair(model, Date().time / 1000)
}
)
}
@@ -129,6 +134,7 @@ fun RobohashAsyncImageProxy(
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
val proxy = remember(model) { model.proxyUrl() }
if (proxy == null) {
RobohashAsyncImage(
robot = robot,

View File

@@ -0,0 +1,127 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun SensitivityWarning(
hasSensitiveContent: Boolean,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
var showContentWarningNote by remember(accountState) {
mutableStateOf(accountState?.account?.showSensitiveContent != true && hasSensitiveContent)
}
if (showContentWarningNote) {
ContentWarningNote() {
showContentWarningNote = false
}
} else {
content()
}
}
@Composable
fun ContentWarningNote(onDismiss: () -> Unit) {
Column() {
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Box(
Modifier
.height(80.dp)
.width(90.dp)
) {
Icon(
imageVector = Icons.Default.Visibility,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(70.dp)
.align(Alignment.BottomStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
imageVector = Icons.Rounded.Warning,
contentDescription = stringResource(R.string.content_warning),
modifier = Modifier
.size(30.dp)
.align(Alignment.TopEnd),
tint = MaterialTheme.colors.onBackground
)
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Text(
text = stringResource(R.string.content_warning),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row() {
Text(
text = stringResource(R.string.content_warning_explanation),
color = Color.Gray,
modifier = Modifier.padding(top = 10.dp),
textAlign = TextAlign.Center
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onDismiss,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(
text = stringResource(R.string.show_anyway),
color = Color.White
)
}
}
}
}
}
}

View File

@@ -10,25 +10,28 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
val context = LocalContext.current
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
var urlPreviewState by remember(url) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
} else {
UrlPreviewState.Empty
}
} ?: UrlPreviewState.Loading
mutableStateOf(default)
}
val scope = rememberCoroutineScope()
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -19,8 +20,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import java.net.URL
@Composable
@@ -41,13 +42,15 @@ fun UrlPreviewCard(
)
) {
Column {
val validatedUrl = URL(previewInfo.url)
val validatedUrl = remember(url) { URL(previewInfo.url) }
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
val imageUrl = remember(url) {
if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
}
AsyncImage(

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
sealed class UrlPreviewState {
object Loading : UrlPreviewState()

View File

@@ -1,6 +1,8 @@
package com.vitorpamplona.amethyst.ui.components
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
@@ -28,6 +30,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -46,6 +49,8 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
import coil.request.ImageRequest
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
@@ -55,27 +60,70 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var muted = mutableStateOf(true)
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, onDialog)
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, onDialog)
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(context).data(thumbUri).build()
val myCover = context.imageLoader.execute(request).drawable
if (myCover != null) {
loadingFinished = Pair(true, myCover)
} else {
loadingFinished = Pair(true, null)
}
} catch (e: Exception) {
Log.e("VideoView", "Fail to load cover $thumbUri", e)
loadingFinished = Pair(true, null)
}
}
}
if (loadingFinished.first) {
if (loadingFinished.second != null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
} else {
VideoView(videoUri, description, onDialog)
}
}
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -104,10 +152,6 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
DisposableEffect(
BoxWithConstraints() {
AndroidView(
@@ -116,9 +160,9 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else {
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
}
},
@@ -130,6 +174,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
@@ -142,11 +187,13 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
MuteButton(mutedInstance) {
mutedInstance.value = !mutedInstance.value
DefaultMutedSetting.value = mutedInstance.value
exoPlayer.volume = if (mutedInstance.value) 0f else 1f
}
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -175,7 +222,10 @@ fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifi
}
onGloballyPositioned { coordinates ->
isVisible = coordinates.isCompletelyVisible(view)
val newIsVisible = coordinates.isCompletelyVisible(view)
if (isVisible != newIsVisible) {
isVisible = newIsVisible
}
}
}
@@ -196,12 +246,14 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
private fun MuteButton(muted: MutableState<Boolean>, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
remember {
Modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
}
) {
Box(
Modifier
@@ -211,23 +263,18 @@ private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle:
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
if (muted.value) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
} else {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",

View File

@@ -17,9 +17,11 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -209,17 +211,21 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
val ratio = remember {
aspectRatio(content.dim)
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
if (ratio != null) {
it.aspectRatio(ratio, maxHeight.isFinite)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = if (maxHeight.isFinite) ContentScale.Inside else ContentScale.FillWidth
if (content.localFile != null && content.localFile.exists()) {
AsyncImage(
@@ -280,12 +286,20 @@ private fun UrlImageView(
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, maxHeight.isFinite)
} ?: this
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
.heightIn(max = maxHeight)
.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, false)
} ?: this
}
}
val contentScale = remember {
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
}
val contentScale = if (maxHeight.isFinite) ContentScale.Inside else ContentScale.FillWidth
AsyncImage(
model = content.url,

View File

@@ -19,7 +19,7 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}

View File

@@ -14,7 +14,7 @@ object BookmarkPublicFeedFilter : FeedFilter<Note>() {
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
return notes.plus(addresses).toSet()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}

View File

@@ -21,7 +21,7 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
return channel.notes
.values
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@@ -35,6 +35,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -28,7 +28,7 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
return messages.roomMessages
.filter { myAccount.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@@ -50,6 +50,6 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -1,9 +1,16 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user.
@@ -19,20 +26,129 @@ object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
val privateMessages = messagingWith.mapNotNull { it ->
privateChatrooms[it]
?.roomMessages
?.sortedBy { it.createdAt() }
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map { it ->
it.notes.values
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.lastOrNull { it.event != null }
}
return (privateMessages + publicChannels)
.filterNotNull()
.sortedBy { it.createdAt() }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPublicMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
if (
(newNotePair.key == oldNote.channelHex()) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
sort(myNewList.toSet()).take(1000)
}
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
emptySet()
} else {
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet()
}
}
private fun filterRelevantPublicMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val followingChannels = account.followingChannels
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is ChannelMessageEvent }.forEach { newNote ->
newNote.channelHex()?.let { channelHex ->
if (channelHex in followingChannels && account.isAcceptable(newNote)) {
val lastNote = newRelevantPublicMessages.get(channelHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPublicMessages.put(channelHex, newNote)
}
} else {
newRelevantPublicMessages.put(channelHex, newNote)
}
}
}
}
return newRelevantPublicMessages
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
if (roomUserHex != null && (newNote.author?.pubkeyHex == me.pubkeyHex || roomUserHex in followingKeySet || me.hasSentMessagesTo(roomUser)) && !account.isHidden(roomUserHex)) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}

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