Compare commits

...

144 Commits

Author SHA1 Message Date
Vitor Pamplona
2e7a955ada v0.68.2 2023-07-13 19:30:52 -04:00
Vitor Pamplona
4aa961437b formatting 2023-07-13 19:23:37 -04:00
Vitor Pamplona
0d9399a1a4 Fixing Right To Left Text 2023-07-13 19:23:26 -04:00
Vitor Pamplona
9b62cf563a Merge pull request #502 from greenart7c3/main
Aways show images/videos in the dialog screen
2023-07-13 18:04:20 -04:00
greenart7c3
7c525e2f2f show videos as links 2023-07-13 18:07:57 -03:00
greenart7c3
63888d55fd fix image loading in ZoomableImageDialog 2023-07-13 17:57:54 -03:00
Vitor Pamplona
7287ead851 v0.68.1 2023-07-13 13:17:20 -04:00
Vitor Pamplona
6fc829f4f1 Merge pull request #486 from greenart7c3/load_media
Optional media pre-loading
2023-07-13 13:14:18 -04:00
Vitor Pamplona
d12b0ce9be Merge pull request #500 from akiomik/fix-string-resource-mapping
Fix string resource mapping when like clicked
2023-07-13 12:56:00 -04:00
Vitor Pamplona
393714eb42 Merge pull request #499 from akiomik/extract-block-report-resource
Extract string resource for Block / Report
2023-07-13 12:55:33 -04:00
Vitor Pamplona
fe363b8a2d Updates readme 2023-07-13 11:27:52 -04:00
Vitor Pamplona
064ce7e6e6 v0.68.0 2023-07-13 11:26:35 -04:00
Vitor Pamplona
af72846998 Support for Classifieds 2023-07-13 11:20:03 -04:00
Vitor Pamplona
899b4f7c90 Quick fix for multiple emoji's in one line 2023-07-13 11:19:07 -04:00
Akiomi Kamakura
2e5b41b711 Add ja to locales_config.xml 2023-07-13 22:57:59 +09:00
Vitor Pamplona
809f048b44 Merge remote-tracking branch 'origin/HEAD' 2023-07-13 08:52:58 -04:00
Vitor Pamplona
51c0c44a2e Formatting 2023-07-13 08:52:47 -04:00
Vitor Pamplona
811d6d8c67 Removes emoji package list selection from Profile threads. 2023-07-13 08:51:54 -04:00
Akiomi Kamakura
c1b9bf42ad Fix string resource mapping when like clicked 2023-07-13 11:05:17 +09:00
Akiomi Kamakura
cafe742cd8 Extract string resource for Block / Report 2023-07-13 11:00:15 +09:00
Vitor Pamplona
ba50ec052b Merge pull request #498 from akiomik/i18n-ja
Add i18n support for ja
2023-07-12 19:59:31 -04:00
Vitor Pamplona
386f97abf8 Updates readme 2023-07-12 19:58:57 -04:00
Akiomi Kamakura
7678b1b100 Add i18n support for ja
Co-authored-by: h3y6e <h3y6e@pm.me>
Co-authored-by: ys3669 <ys3669jp@gmail.com>
2023-07-13 07:51:49 +09:00
Vitor Pamplona
9df82a78e7 v0.67.1 2023-07-12 18:19:47 -04:00
Vitor Pamplona
c87466ada4 when clicking in a repost of a note, redirects the user to the note directly. 2023-07-12 18:19:40 -04:00
Vitor Pamplona
135eb6763a Adjustments to allow removing of the selected emoji packs from the reaction setup 2023-07-12 18:19:20 -04:00
Vitor Pamplona
24a0be9c3a v0.66.7 2023-07-12 17:21:49 -04:00
Vitor Pamplona
f090bc82ae - Support for Emoji Packs
- Support for Personal Emoji Lists
- Support for Custom emoji Reactions
2023-07-12 17:20:06 -04:00
greenart7c3
3254479ee5 add settings icon 2023-07-12 11:41:37 -03:00
greenart7c3
6cc4266435 appcompat needed for language picker and theme 2023-07-12 11:31:13 -03:00
greenart7c3
3e48b293b0 create ThemeViewModel using viewModel contructor 2023-07-12 11:31:13 -03:00
greenart7c3
492b9494ec fix theme not changing after changing language 2023-07-12 11:31:12 -03:00
greenart7c3
b31d2d49d6 do not load preview of long form text if automaticallyShowUrlPreview is false 2023-07-12 11:31:12 -03:00
greenart7c3
245bd4a44d fix lint 2023-07-12 11:31:12 -03:00
greenart7c3
6d4a887d00 use accountviewmodel as parameter instead of MutableState 2023-07-12 11:31:12 -03:00
greenart7c3
f95023f59d replace imageBox with url link with download button 2023-07-12 11:31:12 -03:00
greenart7c3
e69c1cf3a9 remove unused showImage parameters 2023-07-12 11:31:12 -03:00
greenart7c3
b5229f5fe1 only load one image when theres multiple images in the note 2023-07-12 11:31:12 -03:00
greenart7c3
e2ef7cfd70 add string resources 2023-07-12 11:31:12 -03:00
greenart7c3
6e89e69fb6 refactor settings to use TextSpinner and save settings when selecting an option 2023-07-12 11:31:11 -03:00
greenart7c3
1e6ee67932 move back settings to drawer 2023-07-12 11:31:11 -03:00
greenart7c3
0fead83279 fix load image box size 2023-07-12 11:31:11 -03:00
greenart7c3
66952c9212 add url preview settings 2023-07-12 11:31:11 -03:00
greenart7c3
38bb9f457d fix statusbar color when changing theme 2023-07-12 11:31:11 -03:00
greenart7c3
3e615bb152 check for automaticallyStartPlayback setting 2023-07-12 11:31:11 -03:00
greenart7c3
f83a19260f fix build after rebase 2023-07-12 11:31:11 -03:00
greenart7c3
69c3c41196 move drawer options to settings screen 2023-07-12 11:31:11 -03:00
greenart7c3
6c1a4f3971 replace load image button with a rectangle box 2023-07-12 11:31:10 -03:00
greenart7c3
035ba899ee add language picker 2023-07-12 11:31:10 -03:00
greenart7c3
33cc261b62 add settings to change theme 2023-07-12 11:31:10 -03:00
greenart7c3
fee6f19d5e check for load images option 2023-07-12 11:31:10 -03:00
greenart7c3
68e8a13f73 add options to disable auto playback e show images in the settings screen 2023-07-12 11:31:10 -03:00
greenart7c3
a639c3f36d add media settings 2023-07-12 11:31:10 -03:00
greenart7c3
a5f10fef60 created settings screen 2023-07-12 11:31:10 -03:00
greenart7c3
d334c04c47 do not autoplay videos when on mobile data 2023-07-12 11:31:09 -03:00
greenart7c3
7c3d9aad1d add ConnectivityStatus singleton 2023-07-12 11:31:09 -03:00
greenart7c3
ceb81aa2bf created button to load images] 2023-07-12 11:31:09 -03:00
Vitor Pamplona
3ee743ce1a v0.66.7 2023-07-12 10:21:02 -04:00
Vitor Pamplona
d1ffea5432 BugFix for a race condition when deleting events. 2023-07-12 10:20:24 -04:00
Vitor Pamplona
628e73ef69 BugFix for app crashing when the ln invoice is blank 2023-07-12 10:06:27 -04:00
Vitor Pamplona
764928a492 Displays custom emoji reactions 2023-07-12 08:21:10 -04:00
Vitor Pamplona
88638b0cae Fixes the markdown issue with images overlapping the text 2023-07-12 08:16:05 -04:00
Vitor Pamplona
51384e654b Avoiding too much detail in the Log 2023-07-11 17:45:38 -04:00
Vitor Pamplona
aecfaa5fd8 Adds more checks for work in the main thread 2023-07-10 17:22:04 -04:00
Vitor Pamplona
fc5089f7f1 Updates exoplayer version 2023-07-10 14:28:42 -04:00
Vitor Pamplona
25520e7d2a Making nav(route) threaded operation 2023-07-10 14:13:27 -04:00
Vitor Pamplona
7ea5be0152 Refactoring of Badge Box codes and Time classes 2023-07-10 13:50:49 -04:00
Vitor Pamplona
9a7e678bbe Merge pull request #496 from AutumnSunshine/main
Update strings.xml for Tamil
2023-07-09 19:00:17 -04:00
Vitor Pamplona
1f0ca70c47 Adds rendering of approval posts to the NoteMaster of a thread 2023-07-09 18:58:30 -04:00
Vitor Pamplona
93705e5baf v0.66.6 2023-07-09 12:46:56 -04:00
Vitor Pamplona
72e8fa3679 Moves the toggle to only be active in NIP94 and NIP95 images 2023-07-09 12:46:02 -04:00
Vitor Pamplona
c08ce91197 v0.66.5 2023-07-09 12:11:18 -04:00
Vitor Pamplona
b520a0faed Adds sensitive-content tags to NIP94 and NIP95 media 2023-07-09 12:09:21 -04:00
AutumnSunshine
80837abab5 Update strings.xml for Tamil
Added the communities & group translations for Tamil
2023-07-09 11:44:48 +05:30
Vitor Pamplona
5c78135f1a v0.66.4 2023-07-08 17:05:24 -04:00
Vitor Pamplona
386658ed37 Updates vico version 2023-07-08 17:04:59 -04:00
Vitor Pamplona
ab8b0beb02 Making sure multi-threading doesn't duplicate notes in the screen. 2023-07-08 16:35:07 -04:00
Vitor Pamplona
bc89062681 Prints Debug information when the app pauses. 2023-07-08 15:46:46 -04:00
Vitor Pamplona
14685be92d Adds limits to how many messages referencing an event can be downloaded. 2023-07-08 15:46:31 -04:00
Vitor Pamplona
6d565a2960 Simplifies the work of finding an event by relays. 2023-07-08 15:46:02 -04:00
Vitor Pamplona
3fc247d85c Caches participant counting process to speed sorting up 2023-07-08 15:37:05 -04:00
Vitor Pamplona
cfbc4536c6 v0.66.3 2023-07-07 17:15:02 -04:00
Vitor Pamplona
dcf825c43e Slightly faster reactions and zap icons 2023-07-07 17:13:20 -04:00
Vitor Pamplona
8ae05d9964 Formatting 2023-07-07 17:11:47 -04:00
Vitor Pamplona
f11faa87b1 Fix from full height to full screen 2023-07-07 17:09:42 -04:00
Vitor Pamplona
bfbaabd7e8 Fixes translations from Japanese with urls breaking the url 2023-07-07 15:30:51 -04:00
Vitor Pamplona
d35d6a44fe Adding content-sensitivity warnings for LiveStreams 2023-07-07 15:17:20 -04:00
Vitor Pamplona
9c7265565e Avoids triggering the Notification bubble when the live event is simply updated. 2023-07-07 14:40:07 -04:00
Vitor Pamplona
293b023add - Adds hashtags to the description of communities and channels
- Displays descriptions with Translations.
2023-07-07 14:31:04 -04:00
Vitor Pamplona
bee5c1bfa6 v0.66.2 2023-07-07 12:29:22 -04:00
Vitor Pamplona
c93a6dffc4 Creates a fallback for Android9 on OPPO, which doesn't seem able to parse the Japanese regex. 2023-07-07 12:27:57 -04:00
Vitor Pamplona
60dadc0088 BugFix for invalid URLs crashing the app. 2023-07-07 12:15:28 -04:00
Vitor Pamplona
a26c7e3d97 Fixes caching of time display. 2023-07-07 09:45:21 -04:00
Vitor Pamplona
9e2b2cedd6 v0.66.1 2023-07-06 21:10:44 -04:00
Vitor Pamplona
636d59659d Activates reactions and zaps to the channel and community headers. 2023-07-06 21:04:07 -04:00
Vitor Pamplona
63e8391893 Allows notification to the channel headers 2023-07-06 21:03:51 -04:00
Vitor Pamplona
2f354e4b81 Adds EOSE treatment for the Discovery datasource 2023-07-06 21:03:36 -04:00
Vitor Pamplona
b3a369d91b Removes top bar for Community and Hashtag posts 2023-07-06 21:03:05 -04:00
Vitor Pamplona
4406532994 Loads the last 50 messages per following chat. 2023-07-06 21:02:47 -04:00
Vitor Pamplona
8fd5176e22 Makes sure all services stop when the app pauses. 2023-07-06 21:02:18 -04:00
Vitor Pamplona
e3396065c6 Reducing size of the discovery tab strings. 2023-07-06 17:38:55 -04:00
Vitor Pamplona
84600c016e Fixing for emoji families 2023-07-06 17:36:11 -04:00
Vitor Pamplona
64d96ab1d7 Fixes Emoji Joining issues. 2023-07-06 17:24:42 -04:00
Vitor Pamplona
6d6720a511 Adds reactions to Chats and Communities 2023-07-06 15:51:46 -04:00
Vitor Pamplona
17232a1909 Moves communities from /c/name to /n/name 2023-07-06 15:51:28 -04:00
Vitor Pamplona
eb02049507 Turns parsing failures into warnings. 2023-07-06 15:51:14 -04:00
Vitor Pamplona
ed7ad2f51b Adds communities to the readme 2023-07-06 15:50:52 -04:00
Vitor Pamplona
3e0202e6cb v0.66.0 2023-07-06 11:44:33 -04:00
Vitor Pamplona
d59a68ec2e Formatting 2023-07-06 11:41:11 -04:00
Vitor Pamplona
f249884907 Fixes the participants on a community index 2023-07-06 11:41:03 -04:00
Vitor Pamplona
c823d1689f Displays Approval Notes in the notifications 2023-07-06 11:21:35 -04:00
Vitor Pamplona
b10f268e69 - Adds a button to create community posts from the Community screen
- Adds a reference to the reply/community in the new post screen.
2023-07-06 10:57:41 -04:00
Vitor Pamplona
eff21c4f8f Minimizes the height of the tabs 2023-07-06 10:22:44 -04:00
Vitor Pamplona
5537208abb - Adds Basic Support for Communities (172)
- Adds Community discovery screens
- Adds Public Chat discovery
- Adds Community Follow/Unfollow
- Renders Community Posts in feed
- Creates a summary of verified participants in Communities/Chats/Streams
- Restructures Hashtag Screen to the new Screen Building structure
- Remembers scroll position in Live, Community and Chat discovery
2023-07-06 10:11:25 -04:00
Vitor Pamplona
53a5d3e88e Merge pull request #491 from ShinoharaTa/fix_broken_japanese_view
Fixed a bug in Japanese string display
2023-07-05 15:04:28 -04:00
Vitor Pamplona
20f840fd99 Merge pull request #489 from davotoula/translations_update
Updated se/cs/ge translations
2023-07-05 15:00:47 -04:00
tshinohara
dc940c0608 fix lint error. 2023-07-06 03:05:17 +09:00
tshinohara
f5c77d41f1 Bugfix: Url parse disabled in other texts. 2023-07-06 02:47:29 +09:00
tshinohara
014c613577 Bugfix: Change matching pattern. 2023-07-06 01:26:15 +09:00
tshinohara
d369c2c658 Bugfix: Japanese url export disable 2. 2023-07-06 01:23:31 +09:00
tshinohara
536868d3e5 Bugfix: Japanese url export disable. 2023-07-06 01:20:54 +09:00
Vitor Pamplona
5706738898 Initial Support for Communities 2023-07-05 11:22:03 -04:00
Vitor Pamplona
2947c2c7d0 Replaces the Live bar in the top of the feed for Notification dots in the LiveStream bottom button. 2023-07-05 10:28:21 -04:00
Vitor Pamplona
18ac527629 BugFix for invalid hexes in the hex index 2023-07-05 09:09:17 -04:00
Vitor Pamplona
6d80d14421 Updates dependencies 2023-07-05 09:04:42 -04:00
David Kaspar
e60d8c5c20 Updated se/cs/ge translations 2023-07-05 13:03:27 +01:00
Vitor Pamplona
51b4cf444a v0.65.1 2023-07-04 21:02:22 -04:00
Vitor Pamplona
f78ec5cc90 Performance updates 2023-07-04 20:55:01 -04:00
Vitor Pamplona
fd6a63a76c Fixes https://github.com/vitorpamplona/amethyst/issues/487 2023-07-04 19:25:21 -04:00
Vitor Pamplona
e0f186bb94 BugFix for not rendering images on markdown when there is no NIP19 reference in the text. 2023-07-04 12:19:56 -04:00
Vitor Pamplona
9fadab5bb9 v0.65.0 2023-07-04 11:45:35 -04:00
Vitor Pamplona
ea768fb560 Base Streaming view 2023-07-04 11:44:28 -04:00
Vitor Pamplona
71cb18f360 v0.64.4 2023-07-03 08:53:30 -04:00
Vitor Pamplona
3a1694ac9a Renames Hex Check method for better understanding. 2023-07-03 08:36:33 -04:00
Vitor Pamplona
91c47b6e87 BugFix for delayed update of the last messages in the Messages screen 2023-07-03 08:36:12 -04:00
Vitor Pamplona
fe4e03df39 v0.64.3 2023-07-02 16:38:28 -04:00
Vitor Pamplona
d110b9fbbf BugFix for IndexOutOfBoundsException 2023-07-02 15:50:07 -04:00
Vitor Pamplona
dc8c177f20 New Icon for verified follows 2023-07-02 15:47:07 -04:00
Vitor Pamplona
1beacd240e Fixing the lack of the checkmark in the verification icons 2023-07-02 15:46:41 -04:00
Vitor Pamplona
083394b928 Merge pull request #485 from believethehype/main
Added #Cashu
2023-07-02 14:31:54 -04:00
Believethehype
6de8d9d7e0 Update HashtagIcon.kt 2023-07-02 17:02:58 +02:00
Vitor Pamplona
fb4457e767 Fix for last PR 2023-07-01 18:58:01 -04:00
Vitor Pamplona
d4c1907349 Merge pull request #484 from AutumnSunshine/AutumnSunshine-updated-Tamil-translation
Update strings.xml for Tamil translation (values-ta)
2023-07-01 18:42:29 -04:00
AutumnSunshine
c92926f3ab Update strings.xml
Updated Tamil translations for newer additions, building on Vivek's earlier translation.
2023-07-02 03:22:15 +05:30
158 changed files with 7704 additions and 1924 deletions

View File

@@ -63,7 +63,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Custom Emoji (NIP-30)
- [x] Zap Forwarding (NIP-57)
- [x] Text Note References (NIP-27)
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Audio Tracks (zapstr.live) (Kind:31337)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
@@ -71,10 +71,14 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Recommended Application Handlers (NIP-89)
- [x] Events with a Subject (NIP-14)
- [x] Generic Reposts (kind:16)
- [x] Live Activities & Live Chats (NIP-102)
- [x] Live Activities & Live Chats (NIP-53)
- [x] Relay Pages (NIP-11)
- [x] HTTP Auth (NIP-98)
- [x] Zapraiser (NIP-TBD)
- [x] Moderated Communities (NIP-172)
- [x] Emoji Packs (Kind:30030)
- [x] Personal Emoji Lists (Kind:10030)
- [x] Classifieds (Kind:30403)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database

View File

@@ -13,14 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 233
versionName "0.64.2"
versionCode 250
versionName "0.68.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW", "ja")
}
buildTypes {
@@ -116,7 +116,7 @@ dependencies {
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.1'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -134,15 +134,14 @@ dependencies {
// link preview
implementation 'org.jsoup:jsoup:1.16.1'
//implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
implementation "com.google.android.exoplayer:exoplayer:${exoplayer_version}"
// important for proxy / tor
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
implementation "com.google.android.exoplayer:extension-okhttp:${exoplayer_version}"
// Load images from the web.
implementation "io.coil-kt:coil-compose:$coil_version"
@@ -170,9 +169,12 @@ dependencies {
//implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Markdown (With fix for full-image bleeds)
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:main-SNAPSHOT')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:main-SNAPSHOT')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:main-SNAPSHOT')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-ui-material:a0954aba63')
implementation('com.github.vitorpamplona.compose-richtext:richtext-commonmark:a0954aba63')
// Language picker and Theme chooser
implementation 'androidx.appcompat:appcompat:1.6.1'
// Local model for language identification
playImplementation 'com.google.mlkit:language-id:17.0.4'
@@ -199,7 +201,7 @@ dependencies {
implementation 'id.zelory:compressor:3.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.11'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'

View File

@@ -11,17 +11,17 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translatePT(text: String, translateTo: String): String? {
fun translateTo(text: String, translateTo: String): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result
}
fun assertTranslate(expected: String, input: String, translateTo: String) {
assertEquals(null, expected, translatePT(input, translateTo))
assertEquals(null, expected, translateTo(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
val translated = translatePT(input, translateTo)!!
val translated = translateTo(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@@ -86,4 +86,17 @@ class TranslationsTest {
"en"
)
}
@Test
fun testJapaneseTranslationsOfUrl() {
assertTranslateContains(
"https://youtu.be/wMYFmCDy_Eg",
"うちの会社の小さい先輩の話 第1話「うちの会社の先輩は小さくて可愛い」\n" +
"\n" +
"https://youtu.be/wMYFmCDy_Eg\n" +
"\n" +
"先輩がうざい後輩の話と似たような話かと思ったけど、もっとオタクの妄想あるある的なものを詰め込んだやつだ。ワードとかシチュエーションとか、ヒロインのサイズ感とか。知らんけど",
"en"
)
}
}

View File

@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.model.ContactListEvent
@@ -43,6 +44,7 @@ private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
const val FOLLOWING_CHANNELS = "following_channels"
const val FOLLOWING_COMMUNITIES = "following_communities"
const val HIDDEN_USERS = "hidden_users"
const val RELAYS = "relays"
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
@@ -55,6 +57,7 @@ private object PrefKeys {
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -65,6 +68,11 @@ private object PrefKeys {
const val WARN_ABOUT_REPORTS = "warn_about_reports"
const val FILTER_SPAM_FROM_STRANGERS = "filter_spam_from_strangers"
const val LAST_READ_PER_ROUTE = "last_read_route_per_route"
const val AUTOMATICALLY_SHOW_IMAGES = "automatically_show_images"
const val AUTOMATICALLY_START_PLAYBACK = "automatically_start_playback"
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -204,6 +212,7 @@ object LocalPreferences {
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
@@ -216,6 +225,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -232,6 +242,47 @@ object LocalPreferences {
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
}
}.apply()
val globalPrefs = encryptedPreferences()
globalPrefs.edit().apply {
if (account.settings.automaticallyShowImages == null) {
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages!!)
}
if (account.settings.automaticallyStartPlayback == null) {
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback!!)
}
if (account.settings.automaticallyShowUrlPreview == null) {
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
fun updateTheme(theme: Int) {
encryptedPreferences().edit().apply {
putInt(PrefKeys.THEME, theme)
}.apply()
}
fun getTheme(): Int {
encryptedPreferences().apply {
return getInt(PrefKeys.THEME, 0)
}
}
fun getPreferredLanguage(): String {
var language = ""
encryptedPreferences().apply {
language = getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
}
return language
}
fun loadFromEncryptedStorage(): Account? {
@@ -245,6 +296,7 @@ object LocalPreferences {
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString(PrefKeys.RELAYS, "[]"),
@@ -256,6 +308,7 @@ object LocalPreferences {
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultNotificationFollowList = getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val defaultDiscoveryFollowList = getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
val zapAmountChoices = gson.fromJson(
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
@@ -334,9 +387,32 @@ object LocalPreferences {
mapOf()
}
val settings = Settings()
encryptedPreferences().apply {
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false)
} else {
null
}
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false)
} else {
null
}
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false)
} else {
null
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
val a = Account(
loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
followingChannels = followingChannels,
followingCommunities = followingCommunities,
hiddenUsers = hiddenUsers,
localRelays = localRelays,
dontTranslateFrom = dontTranslateFrom,
@@ -349,6 +425,7 @@ object LocalPreferences {
defaultHomeFollowList = defaultHomeFollowList,
defaultStoriesFollowList = defaultStoriesFollowList,
defaultNotificationFollowList = defaultNotificationFollowList,
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
zapPaymentRequest = zapPaymentRequestServer,
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
@@ -358,7 +435,8 @@ object LocalPreferences {
showSensitiveContent = showSensitiveContent,
warnAboutPostsWithReports = warnAboutReports,
filterSpamFromStrangers = filterSpam,
lastReadPerRoute = lastReadPerRoute
lastReadPerRoute = lastReadPerRoute,
settings = settings
)
return a

View File

@@ -13,8 +13,13 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrCommunityDataSource
import com.vitorpamplona.amethyst.service.NostrDiscoveryDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
@@ -66,12 +71,14 @@ object ServiceManager {
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
NostrVideoDataSource.account = myAccount
NostrDiscoveryDataSource.account = myAccount
ImageUploader.account = myAccount
// Notification Elements
NostrHomeDataSource.start()
NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
NostrDiscoveryDataSource.start()
// More Info Data Sources
NostrSingleEventDataSource.start()
@@ -86,8 +93,13 @@ object ServiceManager {
NostrAccountDataSource.stop()
NostrHomeDataSource.stop()
NostrChannelDataSource.stop()
NostrChatroomDataSource.stop()
NostrChatroomListDataSource.stop()
NostrDiscoveryDataSource.stop()
NostrCommunityDataSource.stop()
NostrHashtagDataSource.stop()
NostrSearchEventOrUserDataSource.stop()
NostrSingleChannelDataSource.stop()
NostrSingleEventDataSource.stop()
NostrSingleUserDataSource.stop()

View File

@@ -50,6 +50,7 @@ val KIND3_FOLLOWS = " All Follows "
class Account(
val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels,
var followingCommunities: Set<String> = setOf(),
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
@@ -62,6 +63,7 @@ class Account(
var defaultHomeFollowList: String = KIND3_FOLLOWS,
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var defaultNotificationFollowList: String = GLOBAL_FOLLOWS,
var defaultDiscoveryFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -71,7 +73,8 @@ class Account(
var showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>()
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
var settings: Settings = Settings()
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -82,6 +85,29 @@ class Account(
val saveable: AccountLiveData = AccountLiveData(this)
var userProfileCache: User? = null
fun updateAutomaticallyStartPlayback(
automaticallyStartPlayback: Boolean?
) {
settings.automaticallyStartPlayback = automaticallyStartPlayback
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowUrlPreview(
automaticallyShowUrlPreview: Boolean?
) {
settings.automaticallyShowUrlPreview = automaticallyShowUrlPreview
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: Boolean?
) {
settings.automaticallyShowImages = automaticallyShowImages
live.invalidateData()
saveable.invalidateData()
}
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
warnAboutPostsWithReports = warnReports
@@ -106,6 +132,10 @@ class Account(
return followingChannels.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
}
fun followingCommunities(): List<AddressableNote> {
return followingCommunities.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -170,6 +200,19 @@ class Account(
return
}
if (reaction.startsWith(":")) {
val emojiUrl = EmojiUrl.decode(reaction)
if (emojiUrl != null) {
note.event?.let {
val event = ReactionEvent.create(emojiUrl, it, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
return
}
}
note.event?.let {
val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!)
Client.send(event)
@@ -468,6 +511,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)
@@ -497,6 +541,7 @@ class Account(
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = loggedIn.privKey!!
)
@@ -666,6 +711,51 @@ class Account(
joinChannel(event.id)
}
fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().filter { it != emojiListEvent.address() },
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
fun addEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return
val emojiListEvent = emojiList.event
if (emojiListEvent !is EmojiPackEvent) return
val event = if (usersEmojiList.event == null) {
EmojiPackSelectionEvent.create(
listOf(emojiListEvent.address()),
loggedIn.privKey!!
)
} else {
val noteEvent = usersEmojiList.event
if (noteEvent !is EmojiPackSelectionEvent) return
if (noteEvent.taggedAddresses().any { it == emojiListEvent.address() }) {
return
}
EmojiPackSelectionEvent.create(
noteEvent.taggedAddresses().plus(emojiListEvent.address()),
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun addPrivateBookmark(note: Note) {
if (!isWriteable()) return
@@ -856,6 +946,20 @@ class Account(
saveable.invalidateData()
}
fun joinCommunity(idHex: String) {
followingCommunities = followingCommunities + idHex
live.invalidateData()
saveable.invalidateData()
}
fun leaveCommunity(idHex: String) {
followingCommunities = followingCommunities - idHex
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
live.invalidateData()
@@ -899,6 +1003,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultDiscoveryFollowList(name: String) {
defaultDiscoveryFollowList = name
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -1146,6 +1256,10 @@ class Account(
return user.pubkeyHex in followingKeySet()
}
fun isFollowing(user: HexKey): Boolean {
return user in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author
isAcceptableDirect(note) &&

View File

@@ -76,7 +76,11 @@ class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(Anti
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(AntiSpamState(cache))
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
@@ -145,7 +146,10 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(ChannelState(channel))
}

View File

@@ -7,18 +7,19 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
return when (tag.lowercase()) {
"bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"onyx" -> HashtagIcon(R.drawable.black_heart, "Onyx", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"cashu", "ecash", "nut", "nuts", "deeznuts" -> HashtagIcon(R.drawable.cashu, "Cashu", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
"tunestr", "music", "nowplaying" -> HashtagIcon(R.drawable.tunestr, "Tunestr", primary, Modifier.padding(0.dp, 3.dp, 0.dp, 1.dp))
"weed", "weedstr", "420", "cannabis", "marijuana" -> HashtagIcon(R.drawable.weed, "Weed", Color.Unspecified, Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp))
else -> null
}

View File

@@ -35,7 +35,7 @@ object LocalCache {
fun checkGetOrCreateUser(key: String): User? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateUser(key)
}
return null
@@ -55,6 +55,10 @@ object LocalCache {
return users.get(key)
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return addressables[key]
}
fun getNoteIfExists(key: String): Note? {
return addressables[key] ?: notes[key]
}
@@ -69,14 +73,14 @@ object LocalCache {
if (ATag.isATag(key)) {
return checkGetOrCreateAddressableNote(key)
}
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
val note = getOrCreateNote(key)
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
return if (noteEvent is AddressableEvent) {
// upgrade to the latest
return checkGetOrCreateAddressableNote(noteEvent.address().toTag())
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
} else {
return note
note
}
}
return null
@@ -94,7 +98,7 @@ object LocalCache {
fun checkGetOrCreateChannel(key: String): Channel? {
checkNotInMainThread()
if (isValidHexNpub(key)) {
if (isValidHex(key)) {
return getOrCreateChannel(key) {
PublicChatChannel(key)
}
@@ -108,7 +112,7 @@ object LocalCache {
return null
}
private fun isValidHexNpub(key: String): Boolean {
private fun isValidHex(key: String): Boolean {
if (key.isBlank()) return false
if (key.contains(":")) return false
@@ -317,6 +321,25 @@ object LocalCache {
refreshObservers(note)
}
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: LiveActivitiesEvent, relay: Relay?) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -341,6 +364,63 @@ object LocalCache {
}
}
fun consume(event: EmojiPackSelectionEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: EmojiPackEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: ClassifiedsEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
}
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
refreshObservers(note)
}
}
private fun consume(event: PinListEvent) {
val version = getOrCreateNote(event.id)
val note = getOrCreateAddressableNote(event.address())
@@ -653,6 +733,34 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: CommunityPostApprovalEvent) {
val note = getOrCreateNote(event.id)
// Already processed this event.
if (note.event != null) return
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val communities = event.communities()
val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = communities.map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, eventsApproved)
// Prepares user's profile view.
author.addNote(note)
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
}
refreshObservers(note)
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id)
@@ -798,7 +906,7 @@ object LocalCache {
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Log.d("CM", "New Chat Note (${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Counts the replies
replyTo.forEach {
@@ -1086,8 +1194,7 @@ object LocalCache {
it.idNote().startsWith(text, true)
} + addressables.values.filter {
(it.event as? LongTextNoteEvent)?.content?.contains(text, true) ?: false ||
(it.event as? LongTextNoteEvent)?.title()?.contains(text, true) ?: false ||
(it.event as? LongTextNoteEvent)?.summary()?.contains(text, true) ?: false ||
it.event?.matchTag1With(text) ?: false ||
it.idHex.startsWith(text, true)
}
}
@@ -1138,7 +1245,7 @@ object LocalCache {
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}")
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
}
}
@@ -1218,8 +1325,18 @@ object LocalCache {
is ChannelMessageEvent -> consume(event, relay)
is ChannelMetadataEvent -> consume(event)
is ChannelMuteUserEvent -> consume(event)
is ClassifiedsEvent -> consume(event)
is CommunityDefinitionEvent -> consume(event, relay)
is CommunityPostApprovalEvent -> {
event.containedPost()?.let {
verifyAndConsume(it, relay)
}
consume(event)
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is EmojiPackEvent -> consume(event)
is EmojiPackSelectionEvent -> consume(event)
is FileHeaderEvent -> consume(event, relay)
is FileStorageEvent -> consume(event, relay)

View File

@@ -5,11 +5,13 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@@ -19,7 +21,6 @@ import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.regex.Pattern
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -153,7 +154,8 @@ open class Note(val idHex: String) {
liveSet?.boosts?.invalidateData()
}
fun removeReaction(note: Note) {
val reaction = note.event?.content() ?: "+"
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
@@ -234,7 +236,8 @@ open class Note(val idHex: String) {
}
fun addReaction(note: Note) {
val reaction = note.event?.content() ?: "+"
val tags = note.event?.tags() ?: emptyList()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, setOf(note))
@@ -279,10 +282,63 @@ open class Note(val idHex: String) {
}
}
fun isReactedBy(user: User): String? {
return reactions.filter {
it.value.any { it.author?.pubkeyHex == user.pubkeyHex }
}.keys.firstOrNull()
fun publicZapAuthors(): Set<User> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author
}.toSet()
}
fun publicZapAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return zaps.mapNotNull {
it.key.author?.pubkeyHex
}.toSet()
}
fun reactionAuthors(): Set<User> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author }
}.flatten().toSet()
}
fun reactionAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return reactions.values.map {
it.mapNotNull { it.author?.pubkeyHex }
}.flatten().toSet()
}
fun replyAuthorHexes(): Set<HexKey> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author?.pubkeyHex
}.toSet()
}
fun replyAuthors(): Set<User> {
// Zaps who the requester was the user
return replies.mapNotNull {
it.author
}.toSet()
}
fun boostAuthors(): Set<User> {
// Zaps who the requester was the user
return boosts.mapNotNull {
it.author
}.toSet()
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
@@ -381,7 +437,7 @@ open class Note(val idHex: String) {
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
val dayAgo = TimeUtils.oneDayAgo()
return reports.isNotEmpty() ||
(
author?.reports?.values?.any {
@@ -410,7 +466,7 @@ open class Note(val idHex: String) {
}
fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList()
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
fun reactedBy(loggedIn: User): List<String> {
@@ -418,8 +474,7 @@ open class Note(val idHex: String) {
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > currentTime - (60 * 5) } != null // 5 minute protection
return boosts.firstOrNull { it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo() } != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
@@ -505,7 +560,11 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(NoteState(note))
}

View File

@@ -0,0 +1,75 @@
package com.vitorpamplona.amethyst.model
class ParticipantListBuilder {
private fun addFollowsThatDirectlyParticipateOnToSet(baseNote: Note, followingSet: Set<HexKey>?, set: MutableSet<User>) {
baseNote.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
// Breaks these searchers down to avoid the memory use of creating multiple lists
baseNote.replies.forEach { reply ->
reply.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.boosts.forEach { boost ->
boost.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.zaps.forEach { zapPair ->
zapPair.key.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
baseNote.reactions.forEach { reactionSet ->
reactionSet.value.forEach { reaction ->
reaction.author?.let { author ->
if (author !in set && (followingSet == null || author.pubkeyHex in followingSet)) set.add(author)
}
}
}
}
fun followsThatParticipateOnDirect(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val set = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, set)
return set
}
fun followsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Set<User> {
if (baseNote == null) return mutableSetOf()
val mySet = mutableSetOf<User>()
addFollowsThatDirectlyParticipateOnToSet(baseNote, followingSet, mySet)
baseNote.replies.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
baseNote.boosts.forEach {
it.replyTo?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
}
LocalCache.getChannelIfExists(baseNote.idHex)?.notes?.values?.forEach {
addFollowsThatDirectlyParticipateOnToSet(it, followingSet, mySet)
}
return mySet
}
fun countFollowsThatParticipateOn(baseNote: Note?, followingSet: Set<HexKey>?): Int {
if (baseNote == null) return 0
val list = followsThatParticipateOn(baseNote, followingSet)
return list.size
}
}

View File

@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
@Stable
class Settings(
var automaticallyShowImages: Boolean? = null,
var automaticallyStartPlayback: Boolean? = null,
var preferredLanguage: String? = null,
var automaticallyShowUrlPreview: Boolean? = null
)

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import kotlin.time.ExperimentalTime
@@ -43,16 +42,7 @@ class ThreadAssembler {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
if (aTag != null) {
LocalCache.getOrCreateAddressableNote(aTag)
} else {
return emptySet()
}
} else {
LocalCache.getOrCreateNote(noteId)
}
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()

View File

@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
object TimeUtils {
const val fiveMinutes = 60 * 5
const val oneHour = 60 * 60
const val oneDay = 24 * 60 * 60
fun now() = System.currentTimeMillis() / 1000
fun fiveMinutesAgo() = now() - fiveMinutes
fun oneHourAgo() = now() - oneHour
fun oneDayAgo() = now() - oneDay
fun eightHoursAgo() = now() - (oneHour * 8)
}

View File

@@ -31,6 +31,7 @@ class User(val pubkeyHex: String) {
var latestContactList: ContactListEvent? = null
var latestBookmarkList: BookmarkListEvent? = null
var latestAcceptedBadges: AddressableNote? = null
var notes = setOf<Note>()
private set
@@ -49,8 +50,6 @@ class User(val pubkeyHex: String) {
var privateChatrooms = mapOf<User, Chatroom>()
private set
var acceptedBadges: AddressableNote? = null
fun pubkey() = Hex.decode(pubkeyHex)
fun pubkeyNpub() = pubkey().toNpub()
@@ -146,8 +145,8 @@ class User(val pubkeyHex: String) {
}
fun updateAcceptedBadges(note: AddressableNote) {
if (acceptedBadges?.idHex != note.idHex) {
acceptedBadges = note
if (latestAcceptedBadges?.idHex != note.idHex) {
latestAcceptedBadges = note
liveSet?.badges?.invalidateData()
}
}
@@ -462,7 +461,11 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate() {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(UserState(user))
}

View File

@@ -30,9 +30,11 @@ data class RichTextViewerState(
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ImmutableList<Segment>>
val paragraphs: ImmutableList<ParagraphState>
)
data class ParagraphState(val words: ImmutableList<Segment>, val isRTL: Boolean)
object CachedRichTextParser {
val richTextCache = LruCache<String, RichTextViewerState>(200)
@@ -47,12 +49,15 @@ object CachedRichTextParser {
}
}
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
// Android9 seems to have an issue starting this regex.
val noProtocolUrlValidator = try {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]*[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)")
} catch (e: Exception) {
Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
}
class RichTextParser() {
fun parseText(
@@ -67,8 +72,15 @@ class RichTextParser() {
null
} else if (isNumber(it.originalUrl)) {
null
} else if (it.originalUrl.contains("")) {
null
} else {
it.originalUrl
val pattern = "^((http|https)://)?([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(it.originalUrl)) {
it.originalUrl
} else {
null
}
}
}
@@ -98,13 +110,15 @@ class RichTextParser() {
)
}
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ImmutableList<Segment>> {
var paragraphSegments = persistentListOf<ImmutableList<Segment>>()
private fun findTextSegments(content: String, images: Set<String>, urls: Set<String>, emojis: Map<String, String>, tags: ImmutableListOfLists<String>): ImmutableList<ParagraphState> {
var paragraphSegments = persistentListOf<ParagraphState>()
content.split('\n').forEach { paragraph ->
var segments = persistentListOf<Segment>()
var isDirty = false
val isRTL = isArabic(paragraph)
val wordList = paragraph.split(' ')
wordList.forEach { word ->
val wordSegment = wordIdentifier(word, images, urls, emojis, tags)
@@ -115,13 +129,9 @@ class RichTextParser() {
}
val newSegments = if (isDirty) {
if (isArabic(paragraph)) {
segments.asReversed().toImmutableList()
} else {
segments
}
ParagraphState(segments, isRTL)
} else {
persistentListOf<Segment>(RegularTextSegment(paragraph))
ParagraphState(persistentListOf<Segment>(RegularTextSegment(paragraph)), isRTL)
}
paragraphSegments = paragraphSegments.add(newSegments)
@@ -172,7 +182,8 @@ class RichTextParser() {
} else if (schemelessMatcher.find()) {
val url = schemelessMatcher.group(1) // url
val additionalChars = schemelessMatcher.group(4) // additional chars
if (url != null) {
val pattern = "^([A-Za-z0-9-]+(\\.[A-Za-z0-9]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?".toRegex(RegexOption.IGNORE_CASE)
if (pattern.matches(word)) {
SchemelessUrlSegment(word, url, additionalChars)
} else {
RegularTextSegment(word)
@@ -223,6 +234,12 @@ class RichTextParser() {
return RegularTextSegment(word)
}
companion object {
val longDatePattern: Pattern = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Pattern = Pattern.compile("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Pattern = Pattern.compile("^(-?[\\d.]+)([a-zA-Z%]*)$")
}
}
@Immutable

View File

@@ -53,7 +53,7 @@ class CashuProcessor {
LightningAddressResolver().lnAddressInvoice(
lnaddress = lud16,
milliSats = token.redeemInvoiceAmount * 1000, // Make invoice and leave room for fees
message = "Reedem Cashu",
message = "Redeem Cashu",
onSuccess = { invoice ->
meltInvoice(token, invoice, onSuccess, onError)
},

View File

@@ -16,15 +16,16 @@ class FileHeader(
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null
val description: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
} else {
onError()
}
@@ -39,6 +40,7 @@ class FileHeader(
fileUrl: String,
mimeType: String?,
description: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
) {
@@ -79,7 +81,7 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@@ -51,9 +51,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(BadgeProfilesEvent.kind),
kinds = listOf(BadgeProfilesEvent.kind, EmojiPackSelectionEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
limit = 10
)
)
}

View File

@@ -74,7 +74,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
since = latestEOSEs.users[account.userProfile()]?.followList?.get(chatRoomList)?.relayList,
limit = 25 // Remember to consider spam that is being removed from the UI
limit = 50 // Remember to consider spam that is being removed from the UI
)
)
}

View File

@@ -0,0 +1,41 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrCommunityDataSource : NostrDataSource("SingleCommunityFeed") {
private var communityToWatch: AddressableNote? = null
private fun createLoadCommunityFilter(): TypedFilter? {
val myCommunityToWatch = communityToWatch ?: return null
val community = myCommunityToWatch.event as? CommunityDefinitionEvent ?: return null
return TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
authors = community.moderators().map { it.key }.plus(listOfNotNull(myCommunityToWatch.author?.pubkeyHex)),
tags = mapOf(
"a" to listOf(myCommunityToWatch.address.toTag())
),
kinds = listOf(CommunityPostApprovalEvent.kind),
limit = 500
)
)
}
val loadCommunityChannel = requestNewChannel()
override fun updateChannelFilters() {
loadCommunityChannel.typedFilters = listOfNotNull(createLoadCommunityFilter()).ifEmpty { null }
}
fun loadCommunity(note: AddressableNote?) {
communityToWatch = note
invalidateFilters()
}
}

View File

@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.relays.Client
@@ -12,7 +13,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
import kotlin.Error
@@ -57,7 +57,7 @@ abstract class NostrDataSource(val debugName: String) {
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
subscriptions[channel]?.updateEOSE(TimeUtils.now(), relay.url)
}
}

View File

@@ -0,0 +1,148 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
lateinit var account: Account
val latestEOSEs = EOSEAccount()
val dataSource = "Discovery"
fun createLiveStreamFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
authors = followKeys,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createLiveStreamTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createPublicChatsTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
fun createCommunitiesTagsFilter(): TypedFilter? {
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
if (hashToLoad.isNullOrEmpty()) return null
return TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(dataSource)?.relayList
)
)
}
val discoveryFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull(
createLiveStreamFilter(),
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(),
createPublicChatsTagsFilter(),
createCommunitiesTagsFilter()
).ifEmpty { null }
}
}

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
@@ -66,6 +67,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
TextNoteEvent.kind,
RepostEvent.kind,
GenericRepostEvent.kind,
ClassifiedsEvent.kind,
LongTextNoteEvent.kind,
PollNoteEvent.kind,
HighlightEvent.kind,
@@ -89,7 +91,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ClassifiedsEvent.kind, HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())

View File

@@ -71,7 +71,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
authors = listOf(aTag.pubKeyHex)
)
)
}

View File

@@ -33,7 +33,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
PeopleListEvent.kind, BookmarkListEvent.kind
),
tags = mapOf("a" to listOf(aTag.toTag())),
since = it.lastReactionsDownloadTime
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -49,14 +50,24 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return addressesToWatch.mapNotNull {
it.address()?.let { aTag ->
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex.substring(0, 8))
if (aTag.kind < 25000 && aTag.dTag.isBlank()) {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
authors = listOf(aTag.pubKeyHex)
)
)
)
} else {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(aTag.kind),
tags = mapOf("d" to listOf(aTag.dTag)),
authors = listOf(aTag.pubKeyHex)
)
)
}
}
}
}
@@ -87,7 +98,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
PinListEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
since = it.lastReactionsDownloadTime,
limit = 1000 // Max amount of "replies" to download on a specific event.
)
)
}
@@ -122,6 +134,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
val singleEventChannel = requestNewChannel { time, relayUrl ->
checkNotInMainThread()
eventsToWatch.forEach {
val eose = it.lastReactionsDownloadTime[relayUrl]
if (eose == null) {
@@ -159,16 +173,16 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
}
fun addAddress(aTag: Note) {
if (!addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.plus(aTag)
fun addAddress(addressableNote: Note) {
if (!addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.plus(addressableNote)
invalidateFilters()
}
}
fun removeAddress(aTag: Note) {
if (addressesToWatch.contains(aTag)) {
addressesToWatch = addressesToWatch.minus(aTag)
fun removeAddress(addressableNote: Note) {
if (addressesToWatch.contains(addressableNote)) {
addressesToWatch = addressesToWatch.minus(addressableNote)
invalidateFilters()
}
}

View File

@@ -10,7 +10,7 @@ import okhttp3.Request
data class OnlineCheckResult(val timeInMs: Long, val online: Boolean)
object OnlineChecker {
val checkOnlineCache = LruCache<String, OnlineCheckResult>(10)
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
val fiveMinutes = 1000 * 60 * 5
fun isOnline(url: String?): Boolean {
@@ -21,13 +21,13 @@ object OnlineChecker {
return checkOnlineCache.get(url).online
}
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
return try {
val request = Request.Builder()
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.get()
.build()
val result = HttpClient.getHttpClient().newCall(request).execute().code == 200
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
result

View File

@@ -1,10 +1,13 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean {
println("Test $pos ${Character.charCount(this.codePointAt(pos))}")
return Character.charCount(this.codePointAt(pos)) == 2
}
fun String.firstFullChar(): String {
fun String.firstFullCharOld(): String {
return when (this.length) {
0, 1 -> return this
2, 3 -> return if (isUTF16Char(0)) this.take(2) else this.take(1)
@@ -21,3 +24,85 @@ fun String.firstFullChar(): String {
}
}
}
fun String.firstFullChar(): String {
var isInJoin = false
var hasHadSecondChance = false
var start = 0
var previousCharLength = 0
var next: Int
var codePoint: Int
var i = 0
while (i < this.length) {
codePoint = codePointAt(i)
// Skips if it starts with the join char 0x200D
if (codePoint == 0x200D && previousCharLength == 0) {
next = offsetByCodePoints(i, 1)
start = next
} else {
// If join, searches for the next char
if (codePoint == 0xFE0F) {
} else if (codePoint == 0x200D) {
isInJoin = true
} else {
// stops when two chars are not joined together
if (previousCharLength > 0 && !isInJoin) {
if (Character.charCount(codePoint) == 1 || hasHadSecondChance) {
break
} else {
hasHadSecondChance = true
}
} else {
hasHadSecondChance = false
}
isInJoin = false
}
// next char to evaluate
next = offsetByCodePoints(i, 1)
previousCharLength += (next - i)
}
i = next
}
// if ends in join, then seachers backwards until a char is found.
if (isInJoin) {
i = previousCharLength - 1
while (i > 0) {
if (this[i].code == 0x200D) {
previousCharLength -= 1
} else {
break
}
i -= 1
}
}
return substring(start, start + previousCharLength)
}
fun String.firstFullCharOrEmoji(tags: ImmutableListOfLists<String>): String {
if (length <= 2) {
return firstFullChar()
}
if (this[0] == ':') {
// makes sure an emoji exists
val emojiParts = this.split(":", limit = 3)
if (emojiParts.size >= 2) {
val emojiName = emojiParts[1]
val emojiUrl = tags.lists.firstOrNull() { it.size > 1 && it[1] == emojiName }?.getOrNull(2)
if (emojiUrl != null) {
return ":$emojiName:$emojiUrl"
}
}
}
return firstFullChar()
}

View File

@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.service.connectivitystatus
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
object ConnectivityStatus {
private val onMobileData = mutableStateOf(false)
val isOnMobileData: MutableState<Boolean> = onMobileData
private val onWifi = mutableStateOf(false)
val isOnWifi: MutableState<Boolean> = onWifi
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
onMobileData.value = isOnMobileData
onWifi.value = isOnWifi
}
}

View File

@@ -176,7 +176,7 @@ class LightningAddressResolver() {
null
}
lnInvoice?.get("pr")?.asText()?.let { pr ->
lnInvoice?.get("pr")?.asText()?.ifBlank { null }?.let { pr ->
// Forces LN Invoice amount to be the requested amount.
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {

View File

@@ -103,7 +103,7 @@ object LnInvoiceUtil {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
throw IllegalArgumentException("Cannot decode invoice", e)
throw IllegalArgumentException("Cannot decode invoice: $invoice", e)
}
val matcher = invoicePattern.matcher(invoice)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class AudioTrackEvent(
@@ -42,7 +42,7 @@ class AudioTrackEvent(
cover: String? = null,
subject: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): AudioTrackEvent {
val tags = listOfNotNull(
listOf(MEDIA, media),

View File

@@ -113,7 +113,7 @@ open class BaseTextNoteEvent(
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
val tagAddresses = taggedAddresses().filter { it.kind != CommunityDefinitionEvent.kind }.map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class BookmarkListEvent(
@@ -30,7 +30,7 @@ class BookmarkListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)

View File

@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelCreateEvent(
@@ -26,7 +26,7 @@ class ChannelCreateEvent(
companion object {
const val kind = 40
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelCreateEvent {
val content = try {
if (channelInfo != null) {
gson.toJson(channelInfo)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelHideMessageEvent(
@@ -14,13 +14,19 @@ class ChannelHideMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
fun eventsToHide() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 43
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelHideMessageEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =
messagesToHide?.map {

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMessageEvent(
@@ -14,9 +14,9 @@ class ChannelMessageEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
fun channel() = tags.firstOrNull {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
@@ -34,7 +34,7 @@ class ChannelMessageEvent(
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): ChannelMessageEvent {
@@ -65,3 +65,7 @@ class ChannelMessageEvent(
}
}
}
interface IsInPublicChatChannel {
open fun channel(): String?
}

View File

@@ -3,9 +3,9 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMetadataEvent(
@@ -15,8 +15,9 @@ class ChannelMetadataEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
fun channelInfo() =
try {
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
@@ -28,7 +29,7 @@ class ChannelMetadataEvent(
companion object {
const val kind = 41
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMetadataEvent {
val content =
if (newChannelInfo != null) {
gson.toJson(newChannelInfo)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ChannelMuteUserEvent(
@@ -14,14 +14,19 @@ class ChannelMuteUserEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
) : Event(id, pubKey, createdAt, kind, tags, content, sig), IsInPublicChatChannel {
override fun channel() = tags.firstOrNull {
it.size > 3 && it[0] == "e" && it[3] == "root"
}?.get(1) ?: tags.firstOrNull {
it.size > 1 && it[0] == "e"
}?.get(1)
fun usersToMute() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
companion object {
const val kind = 44
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ChannelMuteUserEvent {
val content = reason
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags =

View File

@@ -0,0 +1,77 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class ClassifiedsEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun title() = tags.firstOrNull { it.size > 1 && it[0] == "title" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun price() = tags.firstOrNull { it.size > 1 && it[0] == "price" }?.let {
Price(it[1], it.getOrNull(2), it.getOrNull(3))
}
fun location() = tags.firstOrNull { it.size > 1 && it[0] == "location" }?.get(1)
fun publishedAt() = try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) {
null
}
companion object {
const val kind = 30402
fun create(
dTag: String,
title: String?,
image: String?,
summary: String?,
price: Price?,
location: String?,
publishedAt: Long?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): ClassifiedsEvent {
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", dTag))
title?.let { tags.add(listOf("title", it)) }
image?.let { tags.add(listOf("image", it)) }
summary?.let { tags.add(listOf("summary", it)) }
price?.let {
if (it.frequency != null && it.currency != null) {
tags.add(listOf("price", it.amount, it.currency, it.frequency))
} else if (it.currency != null) {
tags.add(listOf("price", it.amount, it.currency))
} else {
tags.add(listOf("price", it.amount))
}
}
location?.let { tags.add(listOf("location", it)) }
publishedAt?.let { tags.add(listOf("publishedAt", it.toString())) }
title?.let { tags.add(listOf("title", it)) }
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return ClassifiedsEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}
data class Price(val amount: String, val currency: String?, val frequency: String?)

View File

@@ -0,0 +1,42 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class CommunityDefinitionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
override fun address() = ATag(kind, pubKey, dTag(), null)
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun rules() = tags.firstOrNull { it.size > 1 && it[0] == "rules" }?.get(1)
fun moderators() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
companion object {
const val kind = 34550
fun create(
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): CommunityDefinitionEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = generateId(pubKey, createdAt, kind, tags, "")
val sig = Utils.sign(id, privateKey)
return CommunityDefinitionEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
}
}
}

View File

@@ -0,0 +1,63 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
@Immutable
class CommunityPostApprovalEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun containedPost(): Event? = try {
content.ifBlank { null }?.let {
fromJson(it, Client.lenient)
}
} catch (e: Exception) {
Log.w("CommunityPostEvent", "Failed to Parse Community Approval Contained Post of $id with $content")
null
}
fun communities() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTag = ATag.parse(it[1], it.getOrNull(2))
if (aTag?.kind == CommunityDefinitionEvent.kind) {
aTag
} else {
null
}
}
fun approvedEvents() = tags.filter {
it.size > 1 && (it[0] == "e" || (it[0] == "a" && ATag.parse(it[1], null)?.kind != CommunityDefinitionEvent.kind))
}.map {
it[1]
}
companion object {
const val kind = 4550
fun create(approvedPost: Event, community: CommunityDefinitionEvent, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
val content = approvedPost.toJson()
val communities = listOf("a", community.address().toTag())
val replyToPost = listOf("e", approvedPost.id())
val replyToAuthor = listOf("p", approvedPost.pubKey())
val kind = listOf("k", "${approvedPost.kind()}")
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags: List<List<String>> = listOf(communities, replyToPost, replyToAuthor, kind)
val id = generateId(pubKey, createdAt, GenericRepostEvent.kind, tags, content)
val sig = Utils.sign(id, privateKey)
return GenericRepostEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -5,10 +5,10 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class Contact(val pubKeyHex: String, val relayUri: String?)
@@ -73,7 +73,7 @@ class ContactListEvent(
companion object {
const val kind = 3
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class DeletionEvent(
@@ -20,7 +20,7 @@ class DeletionEvent(
companion object {
const val kind = 5
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): DeletionEvent {
fun create(deleteEvents: List<String>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): DeletionEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = deleteEvents.map { listOf("e", it) }

View File

@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30030
fun create(
name: String = "",
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
tags.add(listOf("d", name))
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return EmojiPackEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
}
}
}
@Immutable
data class EmojiUrl(val code: String, val url: String) {
fun encode(): String {
return ":$code:$url"
}
companion object {
fun decode(encodedEmojiSetup: String): EmojiUrl? {
val emojiParts = encodedEmojiSetup.split(":", limit = 3)
return if (emojiParts.size > 2) {
EmojiUrl(emojiParts[1], emojiParts[2])
} else {
null
}
}
}
}

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.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
@Immutable
class EmojiPackSelectionEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
override fun dTag() = ""
override fun address() = ATag(kind, pubKey, dTag(), null)
companion object {
const val kind = 10030
fun create(
listOfEmojiPacks: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = TimeUtils.now()
): EmojiPackSelectionEvent {
val msg = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
listOfEmojiPacks?.forEach {
tags.add(listOf("a", it.toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return EmojiPackSelectionEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import fr.acinq.secp256k1.Hex
import fr.acinq.secp256k1.Secp256k1
@@ -48,6 +49,8 @@ open class Event(
fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
override fun taggedEmojis() = tags.filter { it.size > 2 && it[0] == "emoji" }.map { EmojiUrl(it[1], it[2]) }
override fun isSensitive() = tags.any {
(it.size > 0 && it[0].equals("content-warning", true)) ||
(it.size > 1 && it[0] == "t" && it[1].equals("nsfw", true)) ||
@@ -69,12 +72,33 @@ open class Event(
override fun hashtags() = tags.filter { it.size > 1 && it[0] == "t" }.map { it[1] }
override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) }
override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex }
override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "t" && it[1].lowercase() in hashtags }?.getOrNull(1)
override fun firstIsTaggedAddressableNote(addressableNotes: Set<String>) = tags.firstOrNull { it.size > 1 && it[0] == "a" && it[1] in addressableNotes }?.getOrNull(1)
override fun isTaggedAddressableKind(kind: Int): Boolean {
val kindStr = kind.toString()
return tags.any { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
}
override fun getTagOfAddressableKind(kind: Int): ATag? {
val kindStr = kind.toString()
val aTag = tags
.firstOrNull { it.size > 1 && it[0] == "a" && it[1].startsWith(kindStr) }
?.getOrNull(1)
?: return null
return ATag.parse(aTag, null)
}
override fun getPoWRank(): Int {
var rank = 0
for (i in 0..id.length) {
@@ -243,8 +267,13 @@ open class Event(
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMetadataEvent.kind -> ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
ChannelMuteUserEvent.kind -> ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
ClassifiedsEvent.kind -> ClassifiedsEvent(id, pubKey, createdAt, tags, content, sig)
CommunityDefinitionEvent.kind -> CommunityDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
CommunityPostApprovalEvent.kind -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.kind -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.kind -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
@@ -293,7 +322,7 @@ open class Event(
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = TimeUtils.now()): Event {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val id = Companion.generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey).toHexKey()

View File

@@ -27,10 +27,16 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedAddressableNote(idHex: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtags: Set<String>): Boolean
fun firstIsTaggedHashes(hashtags: Set<String>): String?
fun firstIsTaggedAddressableNote(addressableNotes: Set<String>): String?
fun isTaggedAddressableKind(kind: Int): Boolean
fun getTagOfAddressableKind(kind: Int): ATag?
fun hashtags(): List<String>
fun getReward(): BigDecimal?
@@ -39,4 +45,7 @@ interface EventInterface {
fun zapAddress(): String?
fun isSensitive(): Boolean
fun zapraiserAmount(): Long?
fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean
}

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileHeaderEvent(
@@ -50,8 +50,9 @@ class FileHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileHeaderEvent {
val tags = listOfNotNull(
listOf(URL, url),
@@ -62,7 +63,14 @@ class FileHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""

View File

@@ -3,10 +3,10 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Base64
import java.util.Date
@Immutable
class FileStorageEvent(
@@ -44,7 +44,7 @@ class FileStorageEvent(
mimeType: String,
data: ByteArray,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileStorageEvent {
val tags = listOfNotNull(
listOf(TYPE, mimeType)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class FileStorageHeaderEvent(
@@ -50,8 +50,9 @@ class FileStorageHeaderEvent(
magnetURI: String? = null,
torrentInfoHash: String? = null,
encryptionKey: AESGCM? = null,
sensitiveContent: Boolean? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): FileStorageHeaderEvent {
val tags = listOfNotNull(
listOf("e", storageEvent.id),
@@ -62,7 +63,14 @@ class FileStorageHeaderEvent(
blurhash?.let { listOf(BLUR_HASH, it) },
magnetURI?.let { listOf(MAGNET_URI, it) },
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) },
sensitiveContent?.let {
if (it) {
listOf("content-warning", "")
} else {
null
}
}
)
val content = description ?: ""

View File

@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class GenericRepostEvent(
@@ -29,7 +29,7 @@ class GenericRepostEvent(
companion object {
const val kind = 16
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): GenericRepostEvent {
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): GenericRepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())

View File

@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.security.MessageDigest
import java.util.Date
@Immutable
class HTTPAuthorizationEvent(
@@ -25,7 +25,7 @@ class HTTPAuthorizationEvent(
method: String,
body: String? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): HTTPAuthorizationEvent {
val sha256 = MessageDigest.getInstance("SHA-256")

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class HighlightEvent(
@@ -26,7 +26,7 @@ class HighlightEvent(
fun create(
msg: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesChatMessageEvent(
@@ -49,7 +49,7 @@ class LiveActivitiesChatMessageEvent(
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
zapRaiserAmount: Long?
): LiveActivitiesChatMessageEvent {

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LiveActivitiesEvent(
@@ -23,20 +23,32 @@ class LiveActivitiesEvent(
fun summary() = tags.firstOrNull { it.size > 1 && it[0] == "summary" }?.get(1)
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun streaming() = tags.firstOrNull { it.size > 1 && it[0] == "streaming" }?.get(1)
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)
fun starts() = tags.firstOrNull { it.size > 1 && it[0] == "starts" }?.get(1)?.toLongOrNull()
fun ends() = tags.firstOrNull { it.size > 1 && it[0] == "ends" }?.get(1)
fun status() = tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1)
fun status() = checkStatus(tags.firstOrNull { it.size > 1 && it[0] == "status" }?.get(1))
fun currentParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "current_participants" }?.get(1)
fun totalParticipants() = tags.firstOrNull { it.size > 1 && it[0] == "total_participants" }?.get(1)
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(2)) }
fun participants() = tags.filter { it.size > 1 && it[0] == "p" }.map { Participant(it[1], it.getOrNull(3)) }
fun checkStatus(eventStatus: String?): String? {
return if (eventStatus == STATUS_LIVE && createdAt < TimeUtils.eightHoursAgo()) {
STATUS_ENDED
} else {
eventStatus
}
}
companion object {
const val kind = 30311
const val STATUS_LIVE = "live"
const val STATUS_PLANNED = "planned"
const val STATUS_ENDED = "ended"
fun create(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LiveActivitiesEvent {
val tags = mutableListOf<List<String>>()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()

View File

@@ -7,11 +7,11 @@ import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.lang.reflect.Type
import java.util.Date
@Immutable
class LnZapPaymentRequestEvent(
@@ -57,7 +57,7 @@ class LnZapPaymentRequestEvent(
lnInvoice: String,
walletServicePubkey: String,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))

View File

@@ -64,7 +64,7 @@ class LnZapRequestEvent(
pollOption: Int?,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey
@@ -104,7 +104,7 @@ class LnZapRequestEvent(
privateKey: ByteArray,
message: String,
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): LnZapRequestEvent {
var content = message
var privkey = privateKey

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class LongTextNoteEvent(
@@ -32,7 +32,7 @@ class LongTextNoteEvent(
companion object {
const val kind = 30023
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LongTextNoteEvent {
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): LongTextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()
replyTos?.forEach {

View File

@@ -7,11 +7,11 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.Gson
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.io.ByteArrayInputStream
import java.util.Date
@Stable
abstract class IdentityClaim(
@@ -171,7 +171,7 @@ class MetadataEvent(
.readerFor(UserMetadata::class.java)
}
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): MetadataEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()

View File

@@ -4,10 +4,10 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.hexToByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class MuteListEvent(
@@ -71,7 +71,7 @@ class MuteListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): MuteListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PeopleListEvent(
@@ -30,7 +30,7 @@ class PeopleListEvent(
privAddresses: List<ATag>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PeopleListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class PinListEvent(
@@ -27,7 +27,7 @@ class PinListEvent(
fun create(
pins: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): PinListEvent {
val tags = mutableListOf<List<String>>()
pins.forEach {

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
const val POLL_OPTION = "poll_option"
const val VALUE_MAXIMUM = "value_maximum"
@@ -45,7 +45,7 @@ class PollNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
pollOptions: Map<Int, String>,
valueMaximum: Int?,
valueMinimum: Int?,

View File

@@ -3,12 +3,12 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.HexValidator
import fr.acinq.secp256k1.Hex
import nostr.postr.Utils
import nostr.postr.toHex
import java.util.Date
@Immutable
class PrivateDmEvent(
@@ -83,7 +83,7 @@ class PrivateDmEvent(
mentions: List<String>? = null,
zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
createdAt: Long = TimeUtils.now(),
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true,
markAsSensitive: Boolean,

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class ReactionEvent(
@@ -22,15 +22,15 @@ class ReactionEvent(
companion object {
const val kind = 7
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun createWarning(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
}
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun createLike(originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
return create("+", originalNote, privateKey, createdAt)
}
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
fun create(content: String, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(listOf("e", originalNote.id()), listOf("p", originalNote.pubKey()))
@@ -42,5 +42,24 @@ class ReactionEvent(
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(emojiUrl: EmojiUrl, originalNote: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReactionEvent {
val content = ":${emojiUrl.code}:"
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
listOf("e", originalNote.id()),
listOf("p", originalNote.pubKey()),
listOf("emoji", emojiUrl.code, emojiUrl.url)
)
if (originalNote is AddressableEvent) {
tags = tags + listOf(listOf("a", originalNote.address().toTag()))
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ReactionEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
}
}

View File

@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.net.URI
import java.util.Date
@Immutable
class RecommendRelayEvent(
@@ -27,7 +27,7 @@ class RecommendRelayEvent(
companion object {
const val kind = 2
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RecommendRelayEvent {
fun create(relay: URI, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RecommendRelayEvent {
val content = relay.toString()
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf<List<String>>()

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelayAuthEvent(
@@ -21,7 +21,7 @@ class RelayAuthEvent(
companion object {
const val kind = 22242
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RelayAuthEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
class RelaySetEvent(
@@ -28,7 +28,7 @@ class RelaySetEvent(
fun create(
relays: List<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): RelaySetEvent {
val tags = mutableListOf<List<String>>()
relays.forEach {

View File

@@ -2,9 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@Immutable
data class ReportedKey(val key: String, val reportType: ReportEvent.ReportType)
@@ -58,7 +58,7 @@ class ReportEvent(
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
@@ -75,7 +75,7 @@ class ReportEvent(
return ReportEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): ReportEvent {
val content = ""
val reportAuthorTag = listOf("p", reportedUser, type.name.lowercase())

View File

@@ -2,10 +2,10 @@ package com.vitorpamplona.amethyst.service.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.relays.Client
import nostr.postr.Utils
import java.util.Date
@Immutable
class RepostEvent(
@@ -29,7 +29,7 @@ class RepostEvent(
companion object {
const val kind = 6
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
fun create(boostedPost: EventInterface, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): RepostEvent {
val content = boostedPost.toJson()
val replyToPost = listOf("e", boostedPost.id())

View File

@@ -4,10 +4,10 @@ import androidx.compose.runtime.Immutable
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@Immutable
class TextNoteEvent(
@@ -40,7 +40,7 @@ class TextNoteEvent(
directMentions: Set<HexKey>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
createdAt: Long = TimeUtils.now()
): TextNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf<List<String>>()

View File

@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
@@ -14,7 +15,6 @@ import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.net.Proxy
import java.time.Duration
import java.util.Date
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT
@@ -179,7 +179,7 @@ class Relay(
socket = null
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
closingTime = TimeUtils.now()
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
@@ -193,7 +193,7 @@ class Relay(
socket = null
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
closingTime = TimeUtils.now()
Log.w("Relay", "Relay onFailure $url, ${response?.message} $response")
t.printStackTrace()
@@ -208,7 +208,7 @@ class Relay(
errorCounter++
isReady = false
afterEOSE = false
closingTime = Date().time / 1000
closingTime = TimeUtils.now()
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
}
@@ -216,7 +216,7 @@ class Relay(
fun disconnect() {
// httpClient.dispatcher.executorService.shutdown()
closingTime = Date().time / 1000
closingTime = TimeUtils.now()
socket?.close(1000, "Normal close")
socket = null
isReady = false
@@ -241,7 +241,7 @@ class Relay(
}
} else {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
if (TimeUtils.now() > closingTime + 60) {
// sends all filters after connection is successful.
requestAndWatch()
}
@@ -254,7 +254,7 @@ class Relay(
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
if (TimeUtils.now() > closingTime + 60) {
// println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}

View File

@@ -10,14 +10,18 @@ import android.os.Bundle
import android.util.Log
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -27,9 +31,11 @@ import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.debugState
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -38,8 +44,7 @@ import kotlinx.coroutines.launch
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
class MainActivity : FragmentActivity() {
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -48,16 +53,22 @@ class MainActivity : FragmentActivity() {
val startingPage = uriToRoute(uri)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
AppCompatDelegate.setApplicationLocales(appLocale)
}
setContent {
AmethystTheme {
val themeViewModel: ThemeViewModel = viewModel()
themeViewModel.onChange(LocalPreferences.getTheme())
AmethystTheme(themeViewModel) {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(this@MainActivity)
}
AccountScreen(accountStateViewModel, startingPage)
AccountScreen(accountStateViewModel, themeViewModel, startingPage)
}
}
}
@@ -91,6 +102,10 @@ class MainActivity : FragmentActivity() {
override fun onPause() {
ServiceManager.pause()
if (BuildConfig.DEBUG) {
debugState(this)
}
super.onPause()
}
@@ -124,6 +139,14 @@ class MainActivity : FragmentActivity() {
networkCapabilities: NetworkCapabilities
) {
super.onCapabilitiesChanged(network, networkCapabilities)
val hasMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
ConnectivityStatus.updateConnectivityStatus(
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR),
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
)
}
// lost network connection

View File

@@ -26,6 +26,7 @@ open class NewMediaModel : ViewModel() {
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
@@ -77,7 +78,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
}
?: run {
viewModelScope.launch {
@@ -97,7 +98,7 @@ open class NewMediaModel : ViewModel() {
server = serverToUse,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
createNIP94Record(imageUrl, mimeType, description)
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
},
onError = {
isUploadingImage = false
@@ -137,7 +138,7 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
@@ -157,6 +158,7 @@ open class NewMediaModel : ViewModel() {
imageUrl,
mimeType,
description,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
uploadingDescription.value = "Sending"
@@ -189,7 +191,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
@@ -199,6 +201,7 @@ open class NewMediaModel : ViewModel() {
"",
mimeType,
description,
sensitiveContent,
onReady = {
uploadingDescription.value = "Signing"
uploadingPercentage.value = 0.40f

View File

@@ -6,11 +6,22 @@ import android.os.Build
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -29,8 +40,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -107,7 +117,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
.fillMaxWidth()
.verticalScroll(scroolState)
) {
ImageVideoPost(postViewModel, account)
ImageVideoPost(postViewModel, accountViewModel)
}
}
}
@@ -123,7 +133,7 @@ fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewModel) {
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.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
@@ -180,7 +190,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
}
} else {
postViewModel.galleryUri?.let {
VideoView(it.toString())
VideoView(it.toString(), accountViewModel = accountViewModel)
}
}
}
@@ -191,7 +201,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
) {
TextSpinner(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
@@ -206,6 +216,18 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
if (isNIP94Server(postViewModel.selectedServer) ||
postViewModel.selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -41,6 +42,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
@@ -52,11 +54,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
@@ -69,6 +73,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
@@ -78,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -89,8 +95,7 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
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 account = remember(accountViewModel) { accountViewModel.account }
val postViewModel: NewPostViewModel = viewModel()
@@ -178,6 +183,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scrollState)
) {
postViewModel.originalNote?.let {
NoteCompose(
baseNote = it,
makeItShort = true,
unPackReply = false,
isQuotedNote = true,
modifier = MaterialTheme.colors.replyModifier,
accountViewModel = accountViewModel,
nav = nav
)
}
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
@@ -244,8 +261,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server ->
postViewModel.upload(url, description, server, context)
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context)
account.changeDefaultFileServer(server)
},
onCancel = {
@@ -255,7 +272,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
}
},
accountViewModel = accountViewModel
)
}
}
@@ -313,9 +331,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
)
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
VideoView(myUrlPreview)
VideoView(myUrlPreview, accountViewModel = accountViewModel)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
}
} else if (startsWithNIP19Scheme(myUrlPreview)) {
val bgColor = MaterialTheme.colors.background
@@ -331,7 +349,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
nav
)
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
}
}
}
@@ -497,7 +515,9 @@ private fun AddZapraiserButton(
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier = Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = MaterialTheme.colors.onBackground
)
Icon(
@@ -512,7 +532,9 @@ private fun AddZapraiserButton(
Icon(
imageVector = Icons.Default.ShowChart,
null,
modifier = Modifier.size(20.dp).align(Alignment.TopStart),
modifier = Modifier
.size(20.dp)
.align(Alignment.TopStart),
tint = BitcoinOrange
)
Icon(
@@ -813,9 +835,10 @@ enum class ServersAvailable {
fun ImageVideoDescription(
uri: Uri,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit,
onAdd: (String, ServersAvailable, Boolean) -> Unit,
onCancel: () -> Unit,
onError: (String) -> Unit
onError: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
@@ -842,6 +865,7 @@ fun ImageVideoDescription(
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
var sensitiveContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
@@ -944,7 +968,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri.toString())
VideoView(uri.toString(), accountViewModel = accountViewModel)
}
}
@@ -969,6 +993,18 @@ fun ImageVideoDescription(
if (isNIP94Server(selectedServer) ||
selectedServer == ServersAvailable.NIP95
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
SettingSwitchItem(
checked = sensitiveContent,
onCheckedChange = { sensitiveContent = it },
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -1000,7 +1036,7 @@ fun ImageVideoDescription(
.fillMaxWidth()
.padding(vertical = 10.dp),
onClick = {
onAdd(message, selectedServer)
onAdd(message, selectedServer, sensitiveContent)
},
shape = QuoteBorder,
colors = ButtonDefaults.buttonColors(
@@ -1019,3 +1055,54 @@ data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@Composable
fun SettingSwitchItem(
modifier: Modifier = Modifier,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
title: Int,
description: Int,
enabled: Boolean = true
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.toggleable(
value = checked,
enabled = enabled,
role = Role.Switch,
onValueChange = onCheckedChange
),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1.0f),
verticalArrangement = Arrangement.spacedBy(3.dp)
) {
val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled
Text(
text = stringResource(id = title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(contentAlpha)
)
Text(
text = stringResource(id = description),
style = MaterialTheme.typography.caption,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(contentAlpha)
)
}
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled
)
}
}

View File

@@ -18,6 +18,7 @@ import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.model.AddressableEvent
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
import com.vitorpamplona.amethyst.service.model.CommunityDefinitionEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
@@ -89,15 +90,17 @@ open class NewPostViewModel() : ViewModel() {
this.replyTos = listOf(replyNote)
}
replyNote.author?.let { replyUser ->
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
this.mentions = currentMentions.plus(replyUser)
}
}
}
} ?: run {
@@ -176,7 +179,7 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, description: String, server: ServersAvailable, context: Context) {
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context) {
isUploadingImage = true
contentToAddUrl = null
@@ -191,7 +194,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description)
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent)
}
} else {
ImageUploader.uploadImage(
@@ -202,7 +205,7 @@ open class NewPostViewModel() : ViewModel() {
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description)
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
@@ -367,13 +370,14 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
description,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it)
@@ -397,13 +401,14 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
sensitiveContent,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }

View File

@@ -0,0 +1,58 @@
package com.vitorpamplona.amethyst.ui.buttons
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = communityIdHex) {
it?.let {
NewCommunityNoteButton(it, accountViewModel, nav)
}
}
}
@Composable
fun NewCommunityNoteButton(note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, note, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(
onClick = { wantsToPost = true },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}

View File

@@ -399,7 +399,7 @@ fun CreateTextWithEmoji(
)
).toSpanStyle()
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
}
}
@@ -458,7 +458,7 @@ fun CreateTextWithEmoji(
).toSpanStyle()
}
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
InLineIconRenderer(emojiList, style, fontSize, maxLines, overflow, modifier)
}
}
@@ -551,7 +551,7 @@ fun CreateClickableTextWithEmoji(
InLineIconRenderer(
emojiLists!!.part2,
LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle(),
maxLines
maxLines = maxLines
)
}
}
@@ -646,6 +646,7 @@ fun ClickableInLineIconRenderer(
fun InLineIconRenderer(
wordsInOrder: ImmutableList<Renderable>,
style: SpanStyle,
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
modifier: Modifier = Modifier
@@ -692,6 +693,7 @@ fun InLineIconRenderer(
Text(
text = annotatedText,
inlineContent = inlineContent,
fontSize = fontSize,
maxLines = maxLines,
overflow = overflow,
modifier = modifier

View File

@@ -17,16 +17,18 @@ import androidx.compose.material.ProvideTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.em
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
@@ -112,7 +114,7 @@ fun RichTextViewer(
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, backgroundColor, tags, nav)
RenderContentAsMarkdown(content, tags, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
}
@@ -136,7 +138,6 @@ private fun RenderRegular(
val currentTextStyle = LocalTextStyle.current
val textStyle = currentTextStyle.copy(
textDirection = TextDirection.Content,
lineHeight = 1.4.em,
color = currentTextStyle.color.takeOrElse {
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
@@ -144,36 +145,58 @@ private fun RenderRegular(
)
MeasureSpaceWidth() { spaceWidth ->
Column {
Column() {
if (canPreview) {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start),
horizontalArrangement = Arrangement.spacedBy(spaceWidth)
) {
paragraph.words.forEach { word ->
RenderWordWithPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
}
} else {
// FlowRow doesn't work well with paragraphs. So we need to split them
state.paragraphs.forEach { paragraph ->
FlowRow(horizontalArrangement = Arrangement.spacedBy(spaceWidth)) {
paragraph.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
val direction = if (paragraph.isRTL) {
LayoutDirection.Rtl
} else {
LayoutDirection.Ltr
}
CompositionLocalProvider(LocalLayoutDirection provides direction) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(spaceWidth),
modifier = Modifier.align(if (paragraph.isRTL) Alignment.End else Alignment.Start)
) {
paragraph.words.forEach { word ->
RenderWordWithoutPreview(
word,
state,
backgroundColor,
textStyle,
accountViewModel,
nav
)
}
}
}
}
@@ -238,8 +261,8 @@ private fun RenderWordWithPreview(
nav: (String) -> Unit
) {
when (word) {
is ImageSegment -> ZoomableContentView(word.segmentText, state)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText)
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText, accountViewModel)
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
@@ -256,9 +279,13 @@ private fun RenderWordWithPreview(
}
@Composable
private fun ZoomableContentView(word: String, state: RichTextViewerState) {
private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel
) {
state.imagesForPager[word]?.let {
ZoomableContentView(it, state.imageList)
ZoomableContentView(it, state.imageList, accountViewModel)
}
}
@@ -292,7 +319,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState<Color>, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
val onClick = remember {
{ link: String ->
@@ -340,9 +367,11 @@ private fun RefreshableContent(content: String, tags: ImmutableListOfLists<Strin
@Composable
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
nip19References = returnNIP19References(content, tags)
onRefresh()
}
}
@@ -389,10 +418,12 @@ private fun ObserveNIP19Event(
@Composable
fun ObserveNote(note: Note, onRefresh: () -> Unit) {
val noteState by note.live().metadata.observeAsState()
val loadedNoteId by note.live().metadata.map {
it.note.event?.id()
}.distinctUntilChanged().observeAsState(note.event?.id())
LaunchedEffect(key1 = noteState) {
if (noteState?.note?.event != null) {
LaunchedEffect(key1 = loadedNoteId) {
if (loadedNoteId != null) {
launch(Dispatchers.IO) {
onRefresh()
}
@@ -426,10 +457,12 @@ private fun ObserveNIP19User(
@Composable
private fun ObserveUser(user: User, onRefresh: () -> Unit) {
val userState by user.live().metadata.observeAsState()
val loadedUserMetaId by user.live().metadata.map {
it.user.info?.latestMetadata?.id
}.distinctUntilChanged().observeAsState(user.info?.latestMetadata?.id)
LaunchedEffect(key1 = userState) {
if (userState?.user?.info != null) {
LaunchedEffect(key1 = loadedUserMetaId) {
if (loadedUserMetaId != null) {
launch(Dispatchers.IO) {
onRefresh()
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.EventInterface
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -43,7 +45,18 @@ fun SensitivityWarning(
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
note.event?.let {
SensitivityWarning(it, accountViewModel, content)
}
}
@Composable
fun SensitivityWarning(
event: EventInterface,
accountViewModel: AccountViewModel,
content: @Composable () -> Unit
) {
val hasSensitiveContent = remember(event) { event.isSensitive() ?: false }
if (hasSensitiveContent) {
SensitivityWarning(accountViewModel, content)
@@ -63,12 +76,14 @@ fun SensitivityWarning(
mutableStateOf(accountState?.account?.showSensitiveContent != true)
}
if (showContentWarningNote) {
ContentWarningNote() {
showContentWarningNote = false
Crossfade(targetState = showContentWarningNote) {
if (it) {
ContentWarningNote() {
showContentWarningNote = false
}
} else {
content()
}
} else {
content()
}
}

View File

@@ -9,37 +9,56 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UrlPreview(url: String, urlText: String) {
var urlPreviewState by remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
)
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
true -> !isMobile
false -> false
else -> true
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
launch(Dispatchers.Main) {
urlPreviewState = it
if (!automaticallyShowUrlPreview) {
ClickableUrl(urlText, url)
} else {
var urlPreviewState by remember(url) {
mutableStateOf(
UrlCachedPreviewer.cache.get(url)?.let { it } ?: UrlPreviewState.Loading
)
}
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url) {
launch(Dispatchers.Main) {
urlPreviewState = it
}
}
}
}
}
}
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)
}
else -> {
ClickableUrl(urlText, url)
Crossfade(
targetState = urlPreviewState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo, accountViewModel)
}
else -> {
ClickableUrl(urlText, url)
}
}
}
}

View File

@@ -19,66 +19,82 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
@Composable
fun UrlPreviewCard(
url: String,
previewInfo: UrlInfoItem
previewInfo: UrlInfoItem,
accountViewModel: AccountViewModel
) {
val uri = LocalUriHandler.current
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column {
AsyncImage(
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
val automaticallyShowUrlPreview = when (settings.automaticallyShowUrlPreview) {
true -> !isMobile
false -> false
else -> true
}
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (!automaticallyShowUrlPreview) {
ClickableUrl(url, url)
} else {
val uri = LocalUriHandler.current
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Row(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = QuoteBorder)
.border(
1.dp,
MaterialTheme.colors.subtleBorder,
QuoteBorder
)
) {
Column {
AsyncImage(
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.description,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -60,6 +61,8 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DataSource
import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
@@ -68,7 +71,13 @@ import kotlin.time.measureTimedValue
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
fun LoadThumbAndThenVideoView(
videoUri: String,
description: String? = null,
thumbUri: String,
accountViewModel: AccountViewModel,
onDialog: ((Boolean) -> Unit)? = null
) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
val context = LocalContext.current
@@ -92,9 +101,9 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog)
} else {
VideoView(videoUri, description, null, onDialog)
VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog)
}
}
}
@@ -105,10 +114,12 @@ fun VideoView(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false,
onDialog: ((Boolean) -> Unit)? = null
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, thumb, onDialog)
VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -118,7 +129,9 @@ fun VideoView1(
videoUri: String,
description: String? = null,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) }
@@ -133,7 +146,7 @@ fun VideoView1(
}
exoPlayerData?.let {
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog)
VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
DisposableEffect(Unit) {
@@ -151,10 +164,12 @@ fun VideoView(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val (value, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog)
val (_, elapsed) = measureTimedValue {
VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo)
}
Log.d("Rendering Metrics", "VideoView $elapsed $videoUri")
}
@@ -166,12 +181,29 @@ fun VideoView1(
exoPlayerData: VideoPlayer,
defaultToStart: Boolean = false,
thumb: VideoThumb? = null,
onDialog: ((Boolean) -> Unit)? = null
onDialog: ((Boolean) -> Unit)? = null,
accountViewModel: AccountViewModel,
alwaysShowVideo: Boolean = false
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
val settings = accountViewModel.account.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val automaticallyStartPlayback = remember {
mutableStateOf(
if (alwaysShowVideo) { true } else {
when (settings.automaticallyStartPlayback) {
true -> !isMobile
false -> false
else -> true
}
}
)
}
exoPlayerData.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
@@ -196,7 +228,11 @@ fun VideoView1(
prepare()
}
RenderVideoPlayer(exoPlayerData, thumb, onDialog)
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog)
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
@@ -230,6 +266,7 @@ data class VideoThumb(
private fun RenderVideoPlayer(
playerData: VideoPlayer,
thumbData: VideoThumb?,
automaticallyStartPlayback: MutableState<Boolean>,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
@@ -241,7 +278,12 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !playerData.exoPlayer.isPlaying) {
if (!automaticallyStartPlayback.value) {
playerData.exoPlayer.stop()
}
if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
} else if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()

View File

@@ -5,7 +5,6 @@ import android.os.Build
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -34,6 +33,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DownloadForOffline
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -70,12 +70,13 @@ import coil.imageLoader
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.BlurHashRequester
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Font17SP
import com.vitorpamplona.amethyst.ui.theme.hashVerified
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -168,7 +169,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) {
fun ZoomableContentView(
content: ZoomableContent,
images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList(),
accountViewModel: AccountViewModel
) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -195,27 +200,53 @@ fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<Zoomable
}
when (content) {
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
is ZoomableUrlVideo -> VideoView(
content.url,
content.description,
accountViewModel = accountViewModel
) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
VideoView(
it.toUri().toString(),
content.description,
accountViewModel = accountViewModel
) { dialogOpen = true }
}
}
if (dialogOpen) {
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel)
}
}
@Composable
private fun LocalImageView(
content: ZoomableLocalImage,
mainImageModifier: Modifier
mainImageModifier: Modifier,
accountViewModel: AccountViewModel?,
alwayShowImage: Boolean = false
) {
if (content.localFile != null && content.localFile.exists()) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
val settings = accountViewModel?.account?.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val showImage = remember {
mutableStateOf(
if (alwayShowImage) { true } else {
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
}
)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
@@ -237,17 +268,26 @@ private fun LocalImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
if (showImage.value) {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
AddedImageFeatures(
painterState,
content,
contentScale,
myModifier,
verifierModifier,
showImage
)
}
} else {
BlankNote()
@@ -257,9 +297,26 @@ private fun LocalImageView(
@Composable
private fun UrlImageView(
content: ZoomableUrlImage,
mainImageModifier: Modifier
mainImageModifier: Modifier,
accountViewModel: AccountViewModel?,
alwayShowImage: Boolean = false
) {
BoxWithConstraints(contentAlignment = Alignment.Center) {
val settings = accountViewModel?.account?.settings
val isMobile = ConnectivityStatus.isOnMobileData.value
val showImage = remember {
mutableStateOf(
if (alwayShowImage) { true } else {
when (settings?.automaticallyShowImages) {
true -> !isMobile
false -> false
else -> true
}
}
)
}
val myModifier = remember {
mainImageModifier
.widthIn(max = maxWidth)
@@ -281,17 +338,45 @@ private fun UrlImageView(
mutableStateOf<AsyncImagePainter.State?>(null)
}
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
if (showImage.value) {
AsyncImage(
model = content.url,
contentDescription = content.description,
contentScale = contentScale,
modifier = myModifier,
onState = {
painterState.value = it
}
)
}
AddedImageFeatures(painterState, content, contentScale, myModifier, verifierModifier)
AddedImageFeatures(
painterState,
content,
contentScale,
myModifier,
verifierModifier,
showImage
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
FlowRow() {
ClickableUrl(urlText = url, url = url)
IconButton(
modifier = Modifier.size(20.dp),
onClick = { showImage.value = true }
) {
Icon(
imageVector = Icons.Default.DownloadForOffline,
null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colors.primary
)
}
}
}
@@ -302,30 +387,35 @@ private fun AddedImageFeatures(
content: ZoomableLocalImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
if (!showImage.value) {
ImageUrlWithDownloadButton(content.uri, showImage)
} else {
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
}
is AsyncImagePainter.State.Error -> {
BlankNote()
}
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
is AsyncImagePainter.State.Error -> {
BlankNote()
}
}
else -> {
is AsyncImagePainter.State.Success -> {
if (content.isVerified != null) {
HashVerificationSymbol(content.isVerified, verifiedModifier)
}
}
else -> {
}
}
}
}
@@ -337,46 +427,51 @@ private fun AddedImageFeatures(
content: ZoomableUrlImage,
contentScale: ContentScale,
myModifier: Modifier,
verifiedModifier: Modifier
verifiedModifier: Modifier,
showImage: MutableState<Boolean>
) {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
if (!showImage.value) {
ImageUrlWithDownloadButton(content.url, showImage)
} else {
var verifiedHash by remember {
mutableStateOf<Boolean?>(null)
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
val context = LocalContext.current
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
when (painter.value) {
null, is AsyncImagePainter.State.Loading -> {
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
DisplayUrlWithLoadingSymbol(content)
}
}
}
verifiedHash?.let {
HashVerificationSymbol(it, verifiedModifier)
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
else -> {
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
val context = LocalContext.current
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content, context)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
}
verifiedHash?.let {
HashVerificationSymbol(it, verifiedModifier)
}
}
else -> {
}
}
}
}
@@ -474,7 +569,12 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
fun ZoomableImageDialog(
imageUrl: ZoomableContent,
allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(),
onDismiss: () -> Unit,
accountViewModel: AccountViewModel
) {
val view = LocalView.current
DisposableEffect(key1 = Unit) {
@@ -516,11 +616,11 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
pagerState = pagerState,
itemsCount = allImages.size,
itemContent = { index ->
RenderImageOrVideo(allImages[index])
RenderImageOrVideo(allImages[index], accountViewModel)
}
)
} else {
RenderImageOrVideo(imageUrl)
RenderImageOrVideo(imageUrl, accountViewModel)
}
Row(
@@ -536,7 +636,10 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
SaveToGallery(
localFile = myContent.localFile,
mimeType = myContent.mimeType
)
}
}
}
@@ -545,23 +648,28 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<Zoom
}
@Composable
fun RenderImageOrVideo(content: ZoomableContent) {
private fun RenderImageOrVideo(content: ZoomableContent, accountViewModel: AccountViewModel) {
val mainModifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState())
if (content is ZoomableUrlImage) {
UrlImageView(content = content, mainImageModifier = mainModifier)
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
VideoView(content.url, content.description, accountViewModel = accountViewModel, alwaysShowVideo = true)
}
} else if (content is ZoomableLocalImage) {
LocalImageView(content = content, mainImageModifier = mainModifier)
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let {
VideoView(it.toUri().toString(), content.description)
VideoView(
it.toUri().toString(),
content.description,
accountViewModel = accountViewModel,
alwaysShowVideo = true
)
}
}
}
@@ -612,7 +720,7 @@ private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Hash Verified",
tint = MaterialTheme.colors.hashVerified,
tint = Color.Unspecified,
modifier = Modifier.size(30.dp)
)
}

View File

@@ -0,0 +1,43 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.CommunityPostApprovalEvent
class CommunityFeedFilter(val note: AddressableNote, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + note.idHex
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val myUnapprovedPosts = collection.asSequence()
.filter { it.author?.pubkeyHex == account.userProfile().pubkeyHex } // made by the logged in user
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // for this community
.filter { it.isNewThread() } // check if it is a new thread
.toSet()
val approvedPosts = collection
.asSequence()
.filter { it.event is CommunityPostApprovalEvent } // Only Approvals
.filter { it.event?.isTaggedAddressableNote(note.idHex) == true } // Of the given community
.mapNotNull { it.replyTo }.flatten() // get approved posts
.filter { it.isNewThread() } // check if it is a new thread
.toSet()
return myUnapprovedPosts + approvedPosts
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allChannelNotes = LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val notes = innerApplyFilter(allChannelNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val createEvents = collection.filter { it.event is ChannelCreateEvent }
val anyOtherChannelEvent = collection
.asSequence()
.filter { it.event is IsInPublicChatChannel }
.mapNotNull { (it.event as? ChannelMessageEvent)?.channel() }
.mapNotNull { LocalCache.checkGetOrCreateNote(it) }
.toSet()
val activities = (createEvents + anyOtherChannelEvent)
.asSequence()
.filter { it.event is ChannelCreateEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
val participantCounts = collection.associate {
it to counter.countFollowsThatParticipateOn(it, followingKeySet)
}
return collection.sortedWith(
compareBy(
{ participantCounts[it] },
{ it.createdAt() },
{ it.idHex }
)
).reversed()
}
}

View File

@@ -0,0 +1,62 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allNotes = LocalCache.addressables.values
val notes = innerApplyFilter(allNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is CommunityDefinitionEvent }
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) == true }
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
val participantCounts = collection.associate {
it to counter.countFollowsThatParticipateOn(it, followingKeySet)
}
return collection.sortedWith(
compareBy(
{ participantCounts[it] },
{ it.createdAt() },
{ it.idHex }
)
).reversed()
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_ENDED
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_PLANNED
open class DiscoverLiveFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
}
override fun feed(): List<Note> {
val allChannelNotes =
LocalCache.channels.values.mapNotNull { LocalCache.getNoteIfExists(it.idHex) }
val allMessageNotes = LocalCache.channels.values.map { it.notes.values }.flatten()
val notes = innerApplyFilter(allChannelNotes + allMessageNotes)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = TimeUtils.now()
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
val followingKeySet =
account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val followingTagSet =
account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
val activities = collection
.asSequence()
.filter { it.event is LiveActivitiesEvent }
.filter {
isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(
followingTagSet
) == true
}
.filter { it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true }
.filter { (it.createdAt() ?: 0) <= now }
.toSet()
return activities
}
override fun sort(collection: Set<Note>): List<Note> {
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
val counter = ParticipantListBuilder()
val participantCounts = collection.associate {
it to counter.countFollowsThatParticipateOn(it, followingKeySet)
}
return collection.sortedWith(
compareBy(
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
{ participantCounts[it] },
{ (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() },
{ it.idHex }
)
).reversed()
}
fun convertStatusToOrder(status: String?): Int {
return when (status) {
STATUS_LIVE -> 2
STATUS_PLANNED -> 1
STATUS_ENDED -> 0
else -> 0
}
}
}

View File

@@ -0,0 +1,20 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent.Companion.STATUS_LIVE
class DiscoverLiveNowFeedFilter(account: Account) : DiscoverLiveFeedFilter(account) {
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val allItems = super.innerApplyFilter(collection)
val onlineOnly = allItems.filter {
val noteEvent = it.event as? LiveActivitiesEvent
noteEvent?.status() == STATUS_LIVE && OnlineChecker.isOnline(noteEvent.streaming())
}
return onlineOnly.toSet()
}
}

View File

@@ -8,19 +8,12 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + tag
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values))
}

View File

@@ -6,7 +6,7 @@ import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex + "-" + HashtagFeedFilter.tag
return account.userProfile().pubkeyHex
}
override fun feed(): List<User> {

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LiveActivitiesChatMessageEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
@@ -29,7 +30,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val now = System.currentTimeMillis() / 1000
val now = TimeUtils.now()
return collection
.asSequence()

View File

@@ -1,57 +0,0 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LiveActivitiesEvent
import java.util.Date
class HomeLiveActivitiesFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList)?.size ?: 0
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList)?.size ?: 0
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList + "-" + followingKeySet + "-" + followingTagSet
}
override fun feed(): List<Note> {
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
return sort(longFormNotes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
checkNotInMainThread()
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val twoHrs = (Date().time / 1000) - 60 * 60 * 2 // hrs
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is LiveActivitiesEvent && noteEvent.createdAt > twoHrs && noteEvent.status() == "live" && OnlineChecker.isOnline(noteEvent.streaming())) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true
}
.toSet()
}
override fun limit() = 2
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}

View File

@@ -4,14 +4,15 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.AudioTrackEvent
import com.vitorpamplona.amethyst.service.model.ClassifiedsEvent
import com.vitorpamplona.amethyst.service.model.GenericRepostEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import java.util.Date
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
@@ -35,14 +36,14 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
val oneMinuteInTheFuture = Date().time / 1000 + (1 * 60) // one minute in the future.
val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future.
val oneHr = 60 * 60
return collection
.asSequence()
.filter { it ->
val noteEvent = it.event
(noteEvent is TextNoteEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent) &&
(isGlobal || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
import com.vitorpamplona.amethyst.service.model.EmojiPackSelectionEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : FeedFilter<Note>() {
@@ -15,7 +16,15 @@ class UserProfileNewThreadFeedFilter(val user: User, val account: Account) : Fee
override fun feed(): List<Note> {
val longFormNotes = LocalCache.addressables.values
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
.filter {
it.author == user &&
(
it.event !is PeopleListEvent &&
it.event !is BookmarkListEvent &&
it.event !is AppRecommendationEvent &&
it.event !is EmojiPackSelectionEvent
)
}
return user.notes
.plus(longFormNotes)

View File

@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TimeUtils
import com.vitorpamplona.amethyst.service.model.*
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -22,7 +23,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
val now = TimeUtils.now()
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -48,6 +49,7 @@ val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Discover,
Route.Notification
)
@@ -100,7 +102,7 @@ private fun RenderBottomMenu(
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column(modifier = Modifier.height(50.dp)) {
Column(modifier = BottomTopHeight) {
Divider(
thickness = 0.25.dp
)

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