Compare commits

...

115 Commits

Author SHA1 Message Date
Vitor Pamplona
554edb8bb9 v0.20.1 2023-02-27 17:18:54 -05:00
Vitor Pamplona
bb3d8e0041 Filtering Notifications to the events that cite the user directly. 2023-02-27 17:14:37 -05:00
Vitor Pamplona
254ed99625 Unpacking replies in Quotes. 2023-02-27 17:14:15 -05:00
Vitor Pamplona
4c916c7957 Marks as read regardless of note.event status. 2023-02-27 15:19:24 -05:00
Vitor Pamplona
7e3cfcdcaf ThreadView avoids double loading. 2023-02-27 15:06:20 -05:00
Vitor Pamplona
528efacf38 Clearly marks the clicked item on ThreadView 2023-02-27 15:06:03 -05:00
Vitor Pamplona
5dac1899fe Allows users to delete usernames 2023-02-27 14:11:54 -05:00
Vitor Pamplona
6bff898fe4 Moves lnurl and nip05 to the services package. 2023-02-27 13:57:43 -05:00
Vitor Pamplona
f065777df2 Only shows translations if the result is different than the origin 2023-02-27 13:54:17 -05:00
Vitor Pamplona
36d865510c adds nip05 to Chatroom headers. 2023-02-27 13:48:26 -05:00
Vitor Pamplona
6558627eef Adding time of the boost and menu options for the boosted event. 2023-02-27 13:41:23 -05:00
Vitor Pamplona
f518969875 Moves Preview and NIP05 verification to IO Threads 2023-02-27 13:39:55 -05:00
Vitor Pamplona
1a7ecca89a Simple String Refactoring 2023-02-27 13:39:16 -05:00
Vitor Pamplona
26da2f514e v0.20.1 2023-02-27 11:30:11 -05:00
Vitor Pamplona
4c0651a6db Organize imports 2023-02-27 11:28:54 -05:00
Vitor Pamplona
59fce55822 Extracting NIP05 Composites in its own file 2023-02-27 11:24:21 -05:00
Vitor Pamplona
c88425e737 changing package names 2023-02-27 10:52:39 -05:00
Vitor Pamplona
d0dc44f3bd 1. Removing _ from the display
2. Checks if NIP 05 is an email before starting to verify to avoid crashes.
3. Allowing ellipsis to avoid breaking the nip05 into multiple lines.
2023-02-27 10:51:50 -05:00
Vitor Pamplona
25cb084a95 Adds NIP05 to the main note in a thread 2023-02-27 10:47:29 -05:00
Vitor Pamplona
0b2d9d6d18 BugFix for invalid nip05 domain names crashing the app. 2023-02-27 10:46:51 -05:00
Vitor Pamplona
145428c8ad v0.20.0 2023-02-26 19:22:33 -05:00
Vitor Pamplona
0cebb53791 Nip05 Support 2023-02-26 19:22:22 -05:00
Vitor Pamplona
8e6a4921a6 v0.19.1 2023-02-26 16:56:55 -05:00
Vitor Pamplona
b806e71396 Crops replies to 3, follows first. 2023-02-26 16:56:12 -05:00
Vitor Pamplona
08f17bca27 Hides bottom navigation when keyboard is visible 2023-02-26 16:55:08 -05:00
Vitor Pamplona
14ec001601 Updating Readme 2023-02-26 15:58:58 -05:00
Vitor Pamplona
dc763088f3 v0.19.0 2023-02-26 12:03:30 -05:00
Vitor Pamplona
c087c5017c NIP-09 Event Deletion support: Works with TextNotes, Likes, Boosts and Reports. 2023-02-26 12:02:07 -05:00
Vitor Pamplona
2eff0626ec v0.18.3 2023-02-25 19:49:31 -05:00
Vitor Pamplona
afc43ccd28 Animating chat reply appearance. 2023-02-25 19:39:03 -05:00
Vitor Pamplona
81f9edf00c Making Toast work to display error messages when Zaps fail. 2023-02-25 19:33:04 -05:00
Vitor Pamplona
bdf2896df9 v0.18.2 2023-02-25 18:16:22 -05:00
Vitor Pamplona
8a2a1887f2 Improvements to ShowMore gradient and size of bubbles in chat. 2023-02-25 18:00:18 -05:00
Vitor Pamplona
26c8808a6b Updating dependencies 2023-02-25 16:18:31 -05:00
Vitor Pamplona
3db1017088 Fixes Ugly Tab Background color in the light theme 2023-02-25 16:15:36 -05:00
Vitor Pamplona
9178135127 Removes the block on Multiple Zaps in a single post (copy paste from the like button, that has a block to avoid multiple likes) 2023-02-25 16:11:03 -05:00
Vitor Pamplona
08b0d8032b Fixes liveSet to delete. | Solves the outofsync error chat has been reporting. 2023-02-25 15:28:42 -05:00
Vitor Pamplona
a8e9189fce Synchronizes Client.connect to avoid random number in relay status 2023-02-25 15:28:07 -05:00
Vitor Pamplona
efbaa0f434 Fixes the URL detector for 2 special characters in Chinese "," and "。" 2023-02-25 15:27:33 -05:00
Vitor Pamplona
0b4d11d5e6 Reverts fixing the English language to avoid issues with translation (now it only translates to english, instead of the local language) and also prepare for i18n work for other languages. 2023-02-25 15:26:53 -05:00
Vitor Pamplona
661cbab4ee v0.18.1 2023-02-24 17:39:58 -05:00
Vitor Pamplona
ba21ae47ae correctly deletes a slave note 2023-02-24 17:39:34 -05:00
Vitor Pamplona
5e066e2485 Updates observers when deleting notes 2023-02-24 17:39:14 -05:00
Vitor Pamplona
3ebcfa111c Improved timing log 2023-02-24 17:24:16 -05:00
Vitor Pamplona
c4046c0a88 Home doesn't need isAcceptable filter since all posts are coming from follows. Much faster. 2023-02-24 17:24:09 -05:00
Vitor Pamplona
3cdc77a277 BugFix Loading one thread after another crashed the app 2023-02-24 17:23:17 -05:00
Vitor Pamplona
951a4be615 Spam count per relay. 2023-02-24 16:36:02 -05:00
Vitor Pamplona
3e7d2a7b68 Removing PlayServices tag to import qrcode model 2023-02-24 16:03:12 -05:00
Vitor Pamplona
19854e2295 Using language identification model bundled into the app 2023-02-24 16:02:54 -05:00
Vitor Pamplona
21b10977d6 Moving barcode scanning model to ship with the app. 2023-02-24 16:02:22 -05:00
Vitor Pamplona
f2ffa4c915 v0.18.0 2023-02-24 13:13:06 -05:00
Vitor Pamplona
bc50f08ca2 Zaps, Likes and Replies in Public Chats and Private Messages 2023-02-24 13:12:31 -05:00
Vitor Pamplona
236177c6ce Fixes bug when note is not included in the returning set. 2023-02-24 12:08:56 -05:00
Vitor Pamplona
b1bdd20255 Makes sure the note is always added in the threadView 2023-02-24 10:08:35 -05:00
Vitor Pamplona
de26417391 Fixing https://github.com/vitorpamplona/amethyst/issues/149 2023-02-24 10:08:21 -05:00
Vitor Pamplona
f7a02c75da Merge pull request #152 from alaznem/patch-1
Update README.md by modifying current features list, checkmarked Zaps
2023-02-24 10:07:22 -05:00
Alazne Morales
2b16472160 Modified current features list, checkmarked Zaps 2023-02-24 12:51:46 +00:00
Vitor Pamplona
2e1b141e38 Showing zap amounts with correct scale. 2023-02-23 21:47:12 -05:00
Vitor Pamplona
7acb735151 v0.17.11 2023-02-23 21:40:29 -05:00
Vitor Pamplona
4fc4126740 Fixes: https://github.com/vitorpamplona/amethyst/issues/126 2023-02-23 21:39:43 -05:00
Vitor Pamplona
04dec3073a Fixes: https://github.com/vitorpamplona/amethyst/issues/151 2023-02-23 21:39:21 -05:00
Vitor Pamplona
40f1ef00a4 Saves Preferred language between each language pair 2023-02-23 21:39:07 -05:00
Vitor Pamplona
d3504129b4 v0.17.10 2023-02-23 18:51:26 -05:00
Vitor Pamplona
3ba6f84a43 Avoids changing the position of the the list when the list updates. 2023-02-23 18:50:48 -05:00
Vitor Pamplona
a399aa0ddd New Zap Amount Customization screen 2023-02-23 18:45:26 -05:00
Vitor Pamplona
b1a3c2542f Marking the selected post in the thread view 2023-02-23 18:45:00 -05:00
Vitor Pamplona
866e31e75b Merge remote-tracking branch 'origin/HEAD' 2023-02-23 17:21:32 -05:00
Vitor Pamplona
2a316de002 Merge pull request #150 from Radiokot/fix/i18n_issues
Fix system-localized strings
2023-02-23 17:21:14 -05:00
Oleg Koretsky
37da339683 Set English locale for the app to avoid seeing system-localized strings, like post time ago 2023-02-23 23:45:23 +02:00
Oleg Koretsky
d35dd18072 Strip non-english resources 2023-02-23 23:21:01 +02:00
Vitor Pamplona
c12ec88e96 Moves the # of downloaded msgs for channel index to 100 in the hopes that none of the 100 are being filtered by spam. 2023-02-23 14:45:39 -05:00
Vitor Pamplona
f455898ac4 Properly removes a note from the database 2023-02-23 14:45:06 -05:00
Vitor Pamplona
f11a33f981 Fixes Channel message deletion bug. 2023-02-23 14:44:40 -05:00
Vitor Pamplona
34417f5c33 Uses a Name to make the translation layer skip ln addresses 2023-02-23 14:44:20 -05:00
Vitor Pamplona
60af6dcd0c v0.17.9 2023-02-22 18:17:01 -05:00
Vitor Pamplona
648a1c7ec1 Adding LnZapRequest to the event observer as well. 2023-02-22 18:13:57 -05:00
Vitor Pamplona
6da089d864 Increase the amount of public channel messages to 10 in the Chatroom List View (to calculate the last one) 2023-02-22 18:13:38 -05:00
Vitor Pamplona
ccd1bdcad0 Calling refresh observers even if there is no observers. 2023-02-22 18:12:46 -05:00
Vitor Pamplona
e4b94e2fea Updating all user's relays when new ContactLists arrive 2023-02-22 18:12:21 -05:00
Vitor Pamplona
bf3847eeaa Putting Follow and Unfollow Buttons into coroutines. 2023-02-22 18:11:46 -05:00
Vitor Pamplona
e4129276a1 Fixing the Relay view in Profile 2023-02-22 18:10:56 -05:00
Vitor Pamplona
dbd2f308c2 Removing the check to see if the feed is different before sending it to the UI. 2023-02-22 18:09:03 -05:00
Vitor Pamplona
61920ed31a Recording duplicated event IDs to avoid marking as spam just because the same event was downloaded from multiple relays. 2023-02-22 18:08:14 -05:00
Vitor Pamplona
8b9b6061b9 The live follows is held at the User not at the Account. 2023-02-22 18:07:01 -05:00
Vitor Pamplona
83f785c5c4 This idea of caching the user was causing issues. 2023-02-22 18:03:49 -05:00
Vitor Pamplona
639e3a5087 Blocks users from Reporting themselves 2023-02-22 16:17:56 -05:00
Vitor Pamplona
bf83f686d7 v0.17.8 2023-02-22 15:23:11 -05:00
Vitor Pamplona
bce30ce5de Trying to solve Notifications crash 2023-02-22 15:06:56 -05:00
Vitor Pamplona
da949fa9fd Avoids breaking tags and image/previews in translation 2023-02-22 14:36:10 -05:00
Vitor Pamplona
da877d5f79 Merge pull request #147 from Radiokot/fix/following_icon_background
Fix slightly broken following icon background on UserPicture
2023-02-22 10:33:50 -05:00
Oleg Koretsky
520923e33a Fix slightly broken following icon background on UserPicture 2023-02-22 17:26:05 +02:00
Vitor Pamplona
c5ceec8015 v0.17.7 2023-02-22 10:20:38 -05:00
Vitor Pamplona
c0c2c668e3 Updating libraries 2023-02-22 10:19:30 -05:00
Vitor Pamplona
6d21653b36 init block is positionally aware.. in some phones... 2023-02-22 09:58:35 -05:00
Vitor Pamplona
39e8289375 Renders invalid tags as is. Should stop crashing in notifications. 2023-02-22 09:58:09 -05:00
Vitor Pamplona
87975f0ca2 Notifications from now on don't show the authors posts. Fixes: https://github.com/vitorpamplona/amethyst/issues/111 2023-02-21 21:52:16 -05:00
Vitor Pamplona
fd70d05768 v0.17.6 2023-02-21 21:30:30 -05:00
Vitor Pamplona
85c7d0bb4a BugFix for alternating follow/unfollow when following multiple people in a row 2023-02-21 21:30:17 -05:00
Vitor Pamplona
63262a1e35 v0.17.5 2023-02-21 17:03:33 -05:00
Vitor Pamplona
987925d837 Reverting to a Class for the Notification View Model 2023-02-21 17:03:26 -05:00
Vitor Pamplona
a2f14c3671 Speeding up userprofile, hidden user search and notification filter time 2023-02-21 16:40:27 -05:00
Vitor Pamplona
110c074993 Adds a space between user picture and name in quotes 2023-02-21 15:48:57 -05:00
Vitor Pamplona
4d56de3335 Observes user and note tags to help loading those from relays. 2023-02-21 15:48:40 -05:00
Vitor Pamplona
5ab3ce84d3 Saves Contact List locally to avoid losing follows. 2023-02-21 15:48:23 -05:00
Vitor Pamplona
e8b09a9ba3 Refactors Notification Cache updates. 2023-02-21 11:33:28 -05:00
Vitor Pamplona
18f25aa4d8 Turns the Zap notification card in to ZapRequest/ZapEvent events. 2023-02-21 11:33:03 -05:00
Vitor Pamplona
a34cf117a0 Makes each notification card trigger it's on Notification time update. 2023-02-21 11:32:10 -05:00
Vitor Pamplona
4b0d1d32a6 Adding an additional refresher of notification icons based on new notes in the database. 2023-02-21 11:31:35 -05:00
Vitor Pamplona
ccf7d78cea v0.17.4 2023-02-21 09:44:17 -05:00
Vitor Pamplona
bc2ef6500a Changes the language from Hide to Block to facilitate PlayStore approvals. 2023-02-21 09:43:34 -05:00
Vitor Pamplona
92b1744c35 Forces conversion of the ID to make sure it's a valid Hex ID and doesn't crash the app later. 2023-02-21 09:43:14 -05:00
Vitor Pamplona
2595d6fa2a Only adds user to Hidden Users after 5 duplicated messages. 2023-02-21 09:42:41 -05:00
Vitor Pamplona
8886ad83e0 v0.17.3: Tentative bugfix for crashes in Notification and Search screens 2023-02-20 20:54:48 -05:00
Vitor Pamplona
e52d7a7881 Adding some space between the profile picture and the name for quoted notes 2023-02-20 20:51:27 -05:00
Vitor Pamplona
d31c11f70d Checking HexKeys across the board.. Too many errors in tags. 2023-02-20 20:49:54 -05:00
127 changed files with 2832 additions and 1324 deletions

View File

@@ -11,22 +11,24 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Notifications Feed
- [x] Global Feed
- [x] Reactions (like, boost, reply)
- [x] Image Preview (gifs, svgs)
- [x] Image/Video Preview (gifs, svgs, etc)
- [x] Url Preview
- [x] View Threads
- [x] Private Messages (NIP-04)
- [x] User Profiles (follow/unfollow)
- [x] Public Chats (NIP-28)
- [x] Notification Bubbles
- [x] Notification Dots
- [x] Reporting and Hide User capability
- [x] Automatic Translations
- [x] Profile Edit
- [x] Relay Edit (separate relay sets for home, dms, public chats, global)
- [x] Dropdown to Link Users when writing
- [x] Lightning Tips
- [ ] Zaps
- [x] Zaps
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
- [ ] Internationalization
- [ ] Infinity Scroll
- [ ] Identity Verification (NIP-05)
- [ ] Event Deletion (NIP-09)
- [ ] Account Creation / Backup Guidance
- [ ] Message Sent feedback

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 58
versionName "0.17.2"
versionCode 76
versionName "0.20.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -43,7 +43,7 @@ android {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion '1.4.0'
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
@@ -54,7 +54,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -62,19 +62,19 @@ dependencies {
implementation("androidx.navigation:navigation-compose:$nav_version")
// Observe Live data as State
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta01'
implementation 'androidx.compose.material:material:1.4.0-alpha05'
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
implementation 'androidx.compose.material:material:1.4.0-beta02'
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
// Lifecycle
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
// Swipe Refresh
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
@@ -98,10 +98,12 @@ dependencies {
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
// Encrypted Key Storage
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
// view videos
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
implementation 'com.google.android.exoplayer:exoplayer:2.18.3'
// Load images from the web.
implementation "io.coil-kt:coil-compose:2.2.2"
@@ -119,6 +121,9 @@ dependencies {
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
// For QR generation
implementation 'com.google.zxing:core:3.5.1'
implementation "androidx.camera:camera-camera2:1.2.1"
@@ -129,12 +134,15 @@ dependencies {
implementation 'com.google.mlkit:vision-common:17.3.0'
// Local Barcode Scanning model
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
// The idea is to make it work for degoogled phones
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'

View File

@@ -0,0 +1,78 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.android.gms.tasks.Tasks
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TranslationsTest {
fun translatePT(text: String, translateTo: String): String? {
val task = LanguageTranslatorService.autoTranslate(text, emptySet(), translateTo)
return Tasks.await(task).result
}
fun assertTranslate(expected: String, input: String, translateTo: String) {
assertEquals(null, expected, translatePT(input, translateTo))
}
fun assertTranslateContains(expected: String, input: String, translateTo: String) {
val translated = translatePT(input, translateTo)!!
assertTrue("'$translated' does not contain '$expected'", translated.contains(expected))
}
@Test
fun testTranslation() {
assertTranslate("Olá mundo", "Hello World", "pt")
}
@Test
fun testTranslationName() {
assertTranslate("Olá Vitor, como você está?", "Hello Vitor, how are you doing?", "pt")
}
@Test
fun testTranslationTag() {
assertTranslate("Você já viu isso, #[0]", "Have you seen this, #[0]", "pt")
}
@Test
fun testTranslationUrl() {
assertTranslateContains("https://t.me/mygroup", "Have you seen this https://t.me/mygroup", "pt")
assertTranslateContains("http://bananas.com", "Have you seen this http://bananas.com", "pt")
assertTranslateContains("http://bananas.com/myimage.jpg", "Have you seen this http://bananas.com/myimage.jpg", "pt")
assertTranslateContains("http://bananas.com?search=true&image=myimage.jpg", "Have you seen this http://bananas.com?search=true&image=myimage.jpg", "pt")
assertTranslate("https://i.imgur.com/EZ3QPsw.jpg", "https://i.imgur.com/EZ3QPsw.jpg", "pt")
assertTranslate("https://HaveYouSeenThis.com", "https://HaveYouSeenThis.com", "pt")
assertTranslate("https://haveyouseenthis.com", "https://haveyouseenthis.com", "pt")
assertTranslate("https://i.imgur.com/asdEZ3QPsw.jpg", "https://i.imgur.com/asdEZ3QPsw.jpg", "pt")
assertTranslateContains("https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "Hi there! \n How are you doing? \n https://i.imgur.com/asdEZ3QPswadfj2389rioasdjf9834riofaj9834aKLL.jpg", "pt")
}
@Test
fun testChineseWithUrlDetector() {
assertTranslate("I entered your home page is very carton, perhaps your attention or other data is too much, and the homepage of others is not so carton. From aMethyst client", "我进入你的主页很卡顿也许是你的关注人数或者其他数据太多了其他人主页没有这么卡顿。来自amethyst客户端", "en")
}
@Test
fun testTranslationEmail() {
assertTranslateContains("vitor@amethyst.social", "Have you seen this vitor@amethyst.social", "pt")
}
@Test
fun testTranslationLnInvoice() {
assertTranslateContains(
"lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn",
"Have you seen this: lnbc12u1p3lvjeupp5a5ecgp45k6pa8tu7rnkgzfuwdy3l5ylv3k5tdzrg4cr8rj2f364sdq5g9kxy7fqd9h8vmmfvdjscqzpgxqyz5vqsp5zuzyetf33aphetf0e80w7tztw6dfsjs4lmvya4cyk8umfsx00qts9qyyssqke9hphcr36zvcav8wr502g0mhfhxpy8m9tt36zttg8vldm2qxw039ulccr8nwy3hjg2sw5vk65e99lwuhrhw0nuya2u57qszltvx7egp74jydn I think I have to pay", "pt"
)
assertTranslateContains(
"lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v",
"Test lnbc10u1p3l0wg0pp5y5y3vxt3429m28uuq56uqhwxadftn67yaarq06h3y9nqapz72n6sdqqxqyjw5q9q7sqqqqqqqqqqqqqqqqqqqqqqqqq9qsqsp5y2tazp42xde3c0tdsz30zqcekrt0lzrneszdtagy2qn7vs0d3p5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glcll7jdvcln4lhw5qqqqlgqqqqqeqqjqdau9jzseecmvmh03h88xyf5f980xx45fmn0cej654v5jr79ye36pww90jwdda38damlmgt54v8rn6q9kywtw057rh4v3wwrmn8fajagqnssr7v", "pt"
)
}
}

View File

@@ -45,10 +45,6 @@
android:name="android.app.lib_name"
android:value="" />
</activity>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
</application>
</manifest>

View File

@@ -4,10 +4,13 @@ import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.toByteArray
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.Event.Companion.getRefinedEvent
import nostr.postr.toHex
class LocalPreferences(context: Context) {
@@ -22,8 +25,10 @@ class LocalPreferences(context: Context) {
remove("hidden_users")
remove("relays")
remove("dontTranslateFrom")
remove("languagePreferences")
remove("translateTo")
remove("zapAmounts")
remove("latestContactList")
}.apply()
}
@@ -35,8 +40,10 @@ class LocalPreferences(context: Context) {
account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.languagePreferences.let { putString("languagePreferences", gson.toJson(it)) }
account.translateTo.let { putString("translateTo", it) }
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
account.backupContactList.let { putString("latestContactList", Event.gson.toJson(it)) }
}.apply()
}
@@ -59,6 +66,24 @@ class LocalPreferences(context: Context) {
object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L)
val latestContactList = try {
getString("latestContactList", null)?.let {
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
}
} catch (e: Throwable) {
e.printStackTrace()
null
}
val languagePreferences = try {
getString("languagePreferences", null)?.let {
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
} ?: mapOf<String,String>()
} catch (e: Throwable) {
e.printStackTrace()
mapOf<String,String>()
}
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -66,8 +91,10 @@ class LocalPreferences(context: Context) {
hiddenUsers,
localRelays,
dontTranslateFrom,
languagePreferences,
translateTo,
zapAmountChoices
zapAmountChoices,
latestContactList
)
} else {
return null

View File

@@ -2,12 +2,14 @@ package com.vitorpamplona.amethyst
import android.content.Context
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.model.Note
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
object NotificationCache {
val lastReadByRoute = mutableMapOf<String, Long>()
@@ -20,7 +22,7 @@ object NotificationCache {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
LocalPreferences(context).saveLastRead(route, timestampInSecs)
invalidateData()
live.invalidateData()
}
}
}
@@ -36,24 +38,31 @@ object NotificationCache {
// Observers line up here.
val live: NotificationLiveData = NotificationLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
}
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() {
postValue(NotificationState(cache))
}

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst
import android.content.Context
import android.graphics.Bitmap
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.util.UUID

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -14,6 +13,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
object ServiceManager {
private var account: Account? = null

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -12,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
@@ -30,6 +30,7 @@ import nostr.postr.Contact
import nostr.postr.Persona
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.DeletionEvent
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.events.PrivateDmEvent
@@ -56,11 +57,18 @@ class Account(
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L)
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
@@ -80,18 +88,23 @@ class Account(
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create(
lastestContactList.follows,
val contactList = userProfile().latestContactList
if (contactList != null && contactList.follows.size > 0) {
val event = ContactListEvent.create(
contactList.follows,
relays,
loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event)
LocalCache.consume(event)
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
LocalCache.consume(event)
}
}
fun sendNewUserMetadata(toString: String) {
@@ -111,10 +124,26 @@ class Account(
}
}
fun reactionTo(note: Note): List<Note> {
return note.reactedBy(userProfile(), "+")
}
fun hasBoosted(note: Note): Boolean {
return boostsTo(note).isNotEmpty()
}
fun boostsTo(note: Note): List<Note> {
return note.boostedBy(userProfile())
}
fun hasReacted(note: Note): Boolean {
return note.hasReacted(userProfile(), "+")
}
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "+")) {
if (hasReacted(note)) {
// has already liked this note
return
}
@@ -129,13 +158,8 @@ class Account(
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
if (!isWriteable()) return null
if (note.hasZapped(userProfile())) {
// has already liked this note
return null
}
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
return null
@@ -144,10 +168,11 @@ class Account(
fun createZapRequestFor(user: User): LnZapRequestEvent? {
return createZapRequestFor(user.pubkeyHex)
}
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
@@ -167,7 +192,7 @@ class Account(
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
LocalCache.consume(event, null)
}
}
@@ -181,13 +206,29 @@ class Account(
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
LocalCache.consume(event, null)
}
fun delete(note: Note) {
delete(listOf(note))
}
fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() }.map { it.idHex }
if (myNotes.isNotEmpty()) {
val event = DeletionEvent.create(myNotes, loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
}
fun boost(note: Note) {
if (!isWriteable()) return
if (note.hasBoosted(userProfile())) {
if (note.hasBoostedInTheLast5Minutes(userProfile())) {
// has already bosted in the past 5mins
return
}
@@ -208,10 +249,11 @@ class Account(
fun follow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
val event = if (lastestContactList != null) {
val contactList = userProfile().latestContactList
val event = if (contactList != null && contactList.follows.size > 0) {
ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
contactList.follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
loggedIn.privKey!!)
} else {
@@ -230,12 +272,14 @@ class Account(
fun unfollow(user: User) {
if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList
if (lastestContactList != null) {
val contactList = userProfile().latestContactList
if (contactList != null && contactList.follows.size > 0) {
val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
contactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
loggedIn.privKey!!)
Client.send(event)
LocalCache.consume(event)
}
@@ -274,14 +318,50 @@ class Account(
LocalCache.consume(signedEvent, null)
}
fun createPrivateMessageWithReply(
recipientPubKey: ByteArray,
msg: String,
replyTos: List<String>? = null, mentions: List<String>? = null,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
advertiseNip18: Boolean = true
): PrivateDmEvent {
val content = Utils.encrypt(
if (advertiseNip18) {
PrivateDmEvent.nip18Advertisement
} else { "" } + msg,
privateKey,
recipientPubKey)
val pubKey = Utils.pubkeyCreate(privateKey)
val tags = mutableListOf<List<String>>()
publishedRecipientPubKey?.let {
tags.add(listOf("p", publishedRecipientPubKey.toHex()))
}
replyTos?.forEach {
tags.add(listOf("e", it))
}
mentions?.forEach {
tags.add(listOf("p", it))
}
val id = Event.generateId(pubKey, createdAt, PrivateDmEvent.kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
val signedEvent = PrivateDmEvent.create(
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
val mentionsHex = emptyList<String>()
val signedEvent = createPrivateMessageWithReply(
recipientPubKey = user.pubkey(),
publishedRecipientPubKey = user.pubkey(),
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -309,28 +389,35 @@ class Account(
fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex
invalidateData(live)
live.invalidateData()
saveable.invalidateData()
}
fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex
invalidateData(live)
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData(live)
live.invalidateData()
saveable.invalidateData()
}
fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex
invalidateData(live)
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
invalidateData(live)
live.invalidateData()
saveable.invalidateData()
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -384,12 +471,35 @@ class Account(
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
invalidateData(liveLanguages)
liveLanguages.invalidateData()
saveable.invalidateData()
}
fun updateTranslateTo(languageCode: String) {
translateTo = languageCode
invalidateData(liveLanguages)
liveLanguages.invalidateData()
saveable.invalidateData()
}
fun prefer(source: String, target: String, preference: String) {
languagePreferences = languagePreferences + Pair("$source,$target", preference)
saveable.invalidateData()
}
fun preferenceBetween(source: String, target: String): String? {
return languagePreferences.get("$source,$target")
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.follows.isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id?.toHex() != newContactList?.id?.toHex()) {
backupContactList = newContactList
saveable.invalidateData()
}
}
fun activeRelays(): Array<Relay>? {
@@ -414,54 +524,10 @@ class Account(
}
}
init {
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged()
}
}
LocalCache.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
LocalCache.spamMessages.snapshot().values.forEach {
if (it !in hiddenUsers) {
val userToBlock = LocalCache.getOrCreateUser(it)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
transientHiddenUsers = transientHiddenUsers + it
}
}
}
}
}
}
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
live.refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun isHidden(user: User) = user in hiddenUsers()
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
return !isHidden(user) // if user hasn't hided this author
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
&& user.reportAuthorsBy( userProfile().follows ).size < 5
}
@@ -496,10 +562,67 @@ class Account(
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
saveable.invalidateData()
}
init {
backupContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
// Observes relays to restart connections
userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged()
}
}
// saves contact list for the next time.
userProfile().live().follows.observeForever {
updateContactListTo(userProfile().latestContactList)
}
// imports transient blocks due to spam.
LocalCache.antiSpam.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach {
if (it.pubkeyHex !in transientHiddenUsers && it.duplicatedMessages.size >= 5) {
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock !in userProfile().follows) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
}
}
}
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() {
postValue(AccountState(account))
}

View File

@@ -0,0 +1,96 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.Event
import nostr.postr.toHex
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
class AntiSpamFilter {
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, Spammer>(1000)
@Synchronized
fun isSpam(event: Event): Boolean {
val idHex = event.id.toHexKey()
// if already processed, ok
if (LocalCache.notes[idHex] != null) return false
// if short message, ok
if (event.content.length < 50) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
// Considers tags so that same replies to different people don't count.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
// Log down offenders
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, Spammer(event.pubKey.toHexKey(), setOf(recentMessages[hash], event.id.toHex())))
liveSpam.invalidateData()
} else {
val spammer = spamMessages.get(hash)
spammer.duplicatedMessages = spammer.duplicatedMessages + event.id.toHex()
liveSpam.invalidateData()
}
return true
}
recentMessages.put(hash, idHex)
return false
}
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
}
class AntiSpamLiveData(val cache: AntiSpamFilter): LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
private fun refresh() {
postValue(AntiSpamState(cache))
}
}
class AntiSpamState(val cache: AntiSpamFilter) {
}

View File

@@ -2,9 +2,7 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.concurrent.ConcurrentHashMap
@@ -51,10 +49,6 @@ class Channel(val idHex: String) {
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
private fun refreshObservers() {
live.refresh()
}
fun pruneOldAndHiddenMessages(account: Account): Set<Note> {
val important = notes.values
.filter { it.author?.let { it1 -> account.isHidden(it1) } == false }
@@ -63,7 +57,7 @@ class Channel(val idHex: String) {
.take(1000)
.toSet()
val toBeRemoved = notes.values.filter { it in important }.toSet()
val toBeRemoved = notes.values.filter { it !in important }.toSet()
toBeRemoved.forEach {
notes.remove(it.idHex)

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import nostr.postr.Bech32
import nostr.postr.Persona
import nostr.postr.bechToBytes
@@ -47,4 +46,37 @@ fun decodePublicKey(key: String): ByteArray {
//} else {
Hex.decode(key)
}
}
data class DirtyKeyInfo(val type: String, val keyHex: String, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
var key = mightBeAKey
if (key.startsWith("nostr:", true)) {
key = key.substring("nostr:".length)
}
key = key.removePrefix("@")
if (key.length < 63)
return null
try {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
if (key.startsWith("nsec1", true)) {
return DirtyKeyInfo("npub", Persona(privKey = keyB32.bechToBytes()).pubKey.toHexKey(), restOfWord)
} else if (key.startsWith("npub1", true)) {
return DirtyKeyInfo("npub", keyB32.bechToBytes().toHexKey(), restOfWord)
} else if (key.startsWith("note1", true)) {
return DirtyKeyInfo("note", keyB32.bechToBytes().toHexKey(), restOfWord)
}
} catch (e: Exception) {
e.printStackTrace()
}
return null
}

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
import android.util.LruCache
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
@@ -24,8 +23,6 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -41,6 +38,7 @@ import nostr.postr.events.PrivateDmEvent
import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
import nostr.postr.toNpub
object LocalCache {
@@ -48,16 +46,15 @@ object LocalCache {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.readerFor(UserMetadata::class.java)
val antiSpam = AntiSpamFilter()
val users = ConcurrentHashMap<HexKey, User>()
val notes = ConcurrentHashMap<HexKey, Note>()
val channels = ConcurrentHashMap<HexKey, Channel>()
val recentMessages = LruCache<Int, String>(1000)
val spamMessages = LruCache<Int, String>(1000)
fun checkGetOrCreateUser(key: HexKey): User? {
fun checkGetOrCreateUser(key: String): User? {
return try {
val checkHex = Hex.decode(key) // Checks if this is a valid Hex
val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex
getOrCreateUser(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create user: $key", e)
@@ -74,6 +71,16 @@ object LocalCache {
}
}
fun checkGetOrCreateNote(key: String): Note? {
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateNote(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create note: $key", e)
null
}
}
@Synchronized
fun getOrCreateNote(idHex: String): Note {
return notes[idHex] ?: run {
@@ -83,6 +90,17 @@ object LocalCache {
}
}
fun checkGetOrCreateChannel(key: String): Channel? {
return try {
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
getOrCreateChannel(key)
} catch (e: IllegalArgumentException) {
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
null
}
}
@Synchronized
fun getOrCreateChannel(key: String): Channel {
return channels[key] ?: run {
@@ -120,36 +138,20 @@ object LocalCache {
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
}
@Synchronized
fun isRepeatedMessageSpam(event: Event): Boolean {
val idHex = event.id.toHexKey()
// if already processed, return
if (notes[idHex] != null) return false
// double list strategy:
// if duplicated, it goes into spam. 1000 spam messages are saved into the spam list.
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
if (event.content.length > 50 && ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null)) {
Log.w("Potential SPAM Message", "${event.id.toHex()} ${recentMessages[hash]} ${spamMessages[hash] != null} ${event.content.replace("\n", " | ")}")
if (spamMessages.get(hash) == null) {
spamMessages.put(hash, event.pubKey.toHexKey())
liveSpam.invalidateData()
}
return true
}
recentMessages.put(hash, idHex)
return false
}
fun consume(event: TextNoteEvent, relay: Relay? = null) {
if (isRepeatedMessageSpam(event)) return
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
@@ -157,7 +159,7 @@ object LocalCache {
if (note.event != null) return
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = replyToWithoutCitations(event).map { getOrCreateNote(it) }
val replyTo = replyToWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
@@ -207,7 +209,10 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
if (event.createdAt > user.updatedFollowsAt && event.follows.isNotEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
user.updateFollows(
event.follows.map {
try {
@@ -224,23 +229,19 @@ object LocalCache {
)
// Saves relay list only if it's a user that is currently been seen
if (user.liveSet?.isInUse() == true) {
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
try {
if (event.content.isNotEmpty()) {
val relays: Map<String, ContactListEvent.ReadWrite> =
Event.gson.fromJson(
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
e.printStackTrace()
user.updateRelays(relays)
}
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
} catch (e: Exception) {
println("relay import issue")
e.printStackTrace()
}
Log.d(
@@ -255,7 +256,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
@@ -266,7 +267,7 @@ object LocalCache {
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -280,7 +281,41 @@ object LocalCache {
}
fun consume(event: DeletionEvent) {
//Log.d("DEL", event.toJson())
var deletedAtLeastOne = false
event.deleteEvents.mapNotNull { notes[it] }.forEach { deleteNote ->
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey.toHexKey()) {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
deleteNote.mentions?.forEach { user ->
user.removeTaggedPost(deleteNote)
user.removeReport(deleteNote)
}
deleteNote.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(deleteNote)
}
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
masterNote.removeReply(deleteNote)
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeReport(deleteNote)
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
}
}
if (deletedAtLeastOne) {
live.invalidateData()
}
}
fun consume(event: RepostEvent) {
@@ -293,7 +328,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
val repliesTo = event.boostedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -324,7 +359,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
val repliesTo = event.originalPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -361,15 +396,20 @@ object LocalCache {
}
}
fun consume(event: ReportEvent) {
fun consume(event: ReportEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
val repliesTo = event.reportedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -410,7 +450,7 @@ object LocalCache {
if (event.channel.isNullOrBlank()) return
// new event
val oldChannel = getOrCreateChannel(event.channel)
val oldChannel = checkGetOrCreateChannel(event.channel) ?: return
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
@@ -430,9 +470,14 @@ object LocalCache {
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
if (isRepeatedMessageSpam(event)) return
if (antiSpam.isSpam(event)) {
relay?.let {
it.spamCounter++
}
return
}
val channel = getOrCreateChannel(event.channel)
val channel = checkGetOrCreateChannel(event.channel) ?: return
val note = getOrCreateNote(event.id.toHex())
channel.addNote(note)
@@ -440,7 +485,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
author.addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
}
@@ -449,7 +494,7 @@ object LocalCache {
val mentions = event.mentions.mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.channel = channel
@@ -489,7 +534,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -525,7 +570,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.zappedAuthor.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost.map { getOrCreateNote(it) }
val repliesTo = event.zappedPost.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -589,6 +634,19 @@ object LocalCache {
toBeRemoved.forEach {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { replyingNote ->
it.removeReply(it)
}
}
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.info.name}")
@@ -639,6 +697,25 @@ object LocalCache {
}
toBeRemoved.forEach {
it.author?.removeNote(it)
// reverts the add
it.mentions?.forEach { user ->
user.removeTaggedPost(it)
}
it.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(it)
}
// Counts the replies
it.replyTo?.forEach { masterNote ->
masterNote.removeReply(it)
masterNote.removeBoost(it)
masterNote.removeReaction(it)
masterNote.removeZap(it)
masterNote.removeReport(it)
}
notes.remove(it.idHex)
}
@@ -647,7 +724,6 @@ object LocalCache {
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
val liveSpam: LocalCacheLiveData = LocalCacheLiveData(this)
private fun refreshObservers() {
live.invalidateData()

View File

@@ -5,23 +5,22 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.math.BigDecimal
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Pattern
import nostr.postr.events.Event
import nostr.postr.toNpub
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
@@ -104,6 +103,42 @@ class Note(val idHex: String) {
}
}
fun removeReply(note: Note) {
replies = replies - note
liveSet?.replies?.invalidateData()
}
fun removeBoost(note: Note) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
}
fun removeReaction(note: Note) {
reactions = reactions - note
liveSet?.reactions?.invalidateData()
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
}
}
}
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
val toRemove = zaps.filterValues { it == note }
zaps = zaps.minus(toRemove.keys)
liveSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -224,14 +259,22 @@ class Note(val idHex: String) {
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
return reactedBy(loggedIn, content).isNotEmpty()
}
fun hasBoosted(loggedIn: User): Boolean {
fun reactedBy(loggedIn: User, content: String): List<Note> {
return reactions.filter { it.author == loggedIn && it.event?.content == content }
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
var liveSet: NoteLiveSet? = null
fun live(): NoteLiveSet {
@@ -242,7 +285,7 @@ class Note(val idHex: String) {
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet = null
}
}
@@ -261,7 +304,8 @@ class NoteLiveSet(u: Note) {
val zaps: NoteLiveData = NoteLiveData(u)
fun isInUse(): Boolean {
return reactions.hasObservers()
return metadata.hasObservers()
|| reactions.hasObservers()
|| boosts.hasObservers()
|| replies.hasObservers()
|| reports.hasObservers()

View File

@@ -9,5 +9,6 @@ data class RelaySetupInfo(
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val spamCount: Int = 0,
val feedTypes: Set<FeedType>
)

View File

@@ -8,14 +8,14 @@ class ThreadAssembler {
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
testedNotes.add(note)
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
if (markedAsRoot != null) return LocalCache.checkGetOrCreateNote(markedAsRoot)
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
if (hasNoReplyTo != null) return hasNoReplyTo
testedNotes.add(note)
// recursive
val roots = note.replyTo?.map {
if (it !in testedNotes)

View File

@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.components.imageExtension
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import com.vitorpamplona.amethyst.ui.components.videoExtension
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job

View File

@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.model
import android.util.Patterns
import androidx.compose.ui.text.toLowerCase
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.LnZapEvent
@@ -74,6 +72,10 @@ class User(val pubkeyHex: String) {
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
}
fun nip05(): String? {
return info?.nip05?.ifBlank { null }
}
fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture
@@ -125,6 +127,10 @@ class User(val pubkeyHex: String) {
}
}
fun removeTaggedPost(note: Note) {
taggedPosts = taggedPosts - note
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
@@ -132,6 +138,10 @@ class User(val pubkeyHex: String) {
}
}
fun removeNote(note: Note) {
notes = notes - note
}
fun clearNotes() {
notes = setOf<Note>()
}
@@ -153,6 +163,17 @@ class User(val pubkeyHex: String) {
}
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (author in reports.keys && reports[author]?.contains(deleteNote) == true ) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
}
}
}
fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
@@ -202,7 +223,7 @@ class User(val pubkeyHex: String) {
}
}
fun addRelay(relay: Relay, eventTime: Long) {
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
@@ -286,7 +307,7 @@ class User(val pubkeyHex: String) {
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == true) {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet = null
}
}
@@ -330,7 +351,11 @@ class UserMetadata {
var banner: String? = null
var website: String? = null
var about: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
@@ -356,7 +381,6 @@ class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)

View File

@@ -0,0 +1,93 @@
package com.vitorpamplona.amethyst.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
return null
}
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
}
withContext(Dispatchers.IO) {
try {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful)
onSuccess(it.body.string())
else
onError("Could not resolve ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct")
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve ${url}. Check if the server up and if the address ${nip05} is correct")
e.printStackTrace()
}
})
} catch (e: java.lang.Exception) {
onError("Could not resolve '${url}': ${e.message}")
}
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchNip05Json(nip05,
onSuccess = {
val nip05url = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError
)
}
}

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -65,7 +64,7 @@ object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
limit = 100 // Remember to consider spam that is being removed from the UI
)
)
}

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
@@ -74,7 +71,7 @@ abstract class NostrDataSource(val debugName: String) {
LocalCache.consume(repostEvent)
}
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
LnZapEvent.kind -> {
val zapEvent = LnZapEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter

View File

@@ -1,12 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
@@ -14,7 +9,6 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
lateinit var account: Account

View File

@@ -1,14 +1,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource("SingleEventFeed") {

View File

@@ -1,7 +1,6 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType

View File

@@ -1,11 +1,11 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -35,7 +35,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -69,7 +69,7 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind,
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.FeedType

View File

@@ -1,15 +1,9 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
private var eventToWatch: String? = null

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.service
import android.util.Log
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -42,7 +40,7 @@ object NostrUserProfileDataSource: NostrDataSource("UserProfileFeed") {
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 100
limit = 200
)
)
}

View File

@@ -8,7 +8,9 @@ import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import java.util.ArrayList
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import java.util.regex.Pattern
class ResultOrError(
var result: String?,
@@ -19,6 +21,7 @@ class ResultOrError(
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b")
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
@@ -43,6 +46,7 @@ object LanguageTranslatorService {
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
@@ -56,20 +60,67 @@ object LanguageTranslatorService {
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
val dict = lnDictionary(text) + urlDictionary(text)
for (paragraph in encodeDictionary(text, dict).split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
var fixedText = task.result.replace("# [","#[") // fixes tags that always return with a space
results.add(decodeDictionary(fixedText, dict))
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
private fun encodeDictionary(text: String, dict: Map<String, String>): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.value, pair.key, true)
}
return newText
}
private fun decodeDictionary(text: String, dict: Map<String, String>): String {
var newText = text
for (pair in dict) {
newText = newText.replace(pair.key, pair.value, true)
}
return newText
}
private fun lnDictionary(text: String): Map<String, String> {
val matcher = lnRegex.matcher(text)
val returningList = mutableMapOf<String, String>()
val counter = 0
while (matcher.find()) {
try {
val lnInvoice = matcher.group()
val short = "Amethystlnindexer${counter}"
returningList.put(short, lnInvoice)
} catch (e: Exception) {
}
}
return returningList
}
private fun urlDictionary(text: String): Map<String, String> {
val parser = UrlDetector(text, UrlDetectorOptions.Default)
val urlsInText = parser.detect()
val counter = 0
return urlsInText.filter { !it.originalUrl.contains("") || !it.originalUrl.contains("") }.associate {
"Amethysturlindexer${counter}" to it.originalUrl
}
}
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == translateTo) {

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.lnurl
package com.vitorpamplona.amethyst.service.lnurl
import androidx.compose.ui.text.toLowerCase
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
@@ -57,12 +56,15 @@ class LightningAddressResolver {
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
if (it.isSuccessful)
onSuccess(it.body.string())
else
onError("Could not resolve ${lnaddress}. Error: ${it.code}. Check if the server up and if the lightning address ${lnaddress} is correct")
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve User's LNURL address from ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
onError("Could not resolve ${url}. Check if the server up and if the lightning address ${lnaddress} is correct")
e.printStackTrace()
}
})
@@ -93,12 +95,15 @@ class LightningAddressResolver {
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSuccess(response.body.string())
if (it.isSuccessful)
onSuccess(response.body.string())
else
onError("Could not fetch invoice from $lnCallback")
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not fetch an invoice from ${lnCallback}. Message ${e.message}")
onError("Could not fetch an invoice from $lnCallback. Message ${e.message}")
e.printStackTrace()
}
})
@@ -125,6 +130,7 @@ class LightningAddressResolver {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val callback = lnurlp?.get("callback")?.asText()
if (callback == null) {
@@ -145,7 +151,7 @@ class LightningAddressResolver {
lnInvoice?.get("pr")?.asText()?.let { pr ->
onSuccess(pr)
}
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
},
onError = onError
)

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.lnurl
package com.vitorpamplona.amethyst.service.lnurl
import androidx.compose.ui.text.toLowerCase
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelHideMessageEvent (
id: ByteArray,

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class ChannelMessageEvent (
id: ByteArray,

View File

@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import java.util.Date
import nostr.postr.ContactMetaData
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMetadataEvent (
id: ByteArray,

View File

@@ -3,8 +3,6 @@ package com.vitorpamplona.amethyst.service.model
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toHex
class ChannelMuteUserEvent (
id: ByteArray,

View File

@@ -1,12 +1,9 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.relays.Client
import java.math.BigDecimal
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event
import nostr.postr.toHex
class LnZapEvent (
id: ByteArray,

View File

@@ -1,9 +1,7 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.service.relays.Client
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service.model
import androidx.compose.ui.text.toUpperCase
import java.util.Date
import nostr.postr.Utils
import nostr.postr.events.Event

View File

@@ -26,6 +26,7 @@ object Client: RelayPool.Listener {
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
@Synchronized
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import java.util.Date
import java.util.concurrent.TimeUnit
import nostr.postr.events.Event
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -31,6 +30,7 @@ class Relay(
private var isReady: Boolean = false
var eventDownloadCounter = 0
var spamCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject

View File

@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui
import android.content.ComponentCallbacks2
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -16,24 +14,13 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.bechToBytes
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -64,11 +51,11 @@ class MainActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel {
val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext))
}
AccountScreen(accountViewModel, startingPage)
AccountScreen(accountStateViewModel, startingPage)
}
}
}
@@ -78,6 +65,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
// Only starts after login
ServiceManager.start()
}

View File

@@ -9,11 +9,11 @@ import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import java.io.File
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {

View File

@@ -3,12 +3,19 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import okhttp3.*
import java.io.IOException
import java.util.UUID
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
object ImageUploader {
fun uploadImage(

View File

@@ -17,18 +17,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {

View File

@@ -4,12 +4,8 @@ import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toHex
class NewChannelViewModel: ViewModel() {
private var account: Account? = null
@@ -38,8 +34,6 @@ class NewChannelViewModel: ViewModel() {
channelDescription.value.text,
channelPicture.value.text
)
LocalPreferences(context).saveToEncryptedStorage(account)
} else
account.sendChangeChannel(
channelName.value.text,

View File

@@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.UserLine
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent
@@ -108,7 +108,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
) {
Column(modifier = Modifier.fillMaxWidth().verticalScroll(scroolState)) {
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "") {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "") {
postViewModel.removeFromReplyList(it)
}
}

View File

@@ -9,12 +9,15 @@ import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.parseDirtyWordForKey
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import nostr.postr.toNpub
class NewPostViewModel: ViewModel() {
private var account: Account? = null
@@ -64,36 +67,24 @@ class NewPostViewModel: ViewModel() {
fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
return (if (originalNote?.channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (replyTos?.indexOf(note) ?: 0)
return (if (originalNote?.channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun sendPost() {
// adds all references to mentions and reply tos
message.text.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val results = parseDirtyWordForKey(word)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
addUserToMentions(user)
} else if (word.startsWith("@note") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val note = LocalCache.getOrCreateNote(key.toHexKey())
addNoteToReplyTos(note)
}
} catch (e: Exception) {
e.printStackTrace()
if (results?.type == "npub") {
addUserToMentions(LocalCache.getOrCreateUser(results.keyHex))
} else if (results?.type == "note") {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.keyHex))
}
}
}
@@ -101,29 +92,16 @@ class NewPostViewModel: ViewModel() {
// Tags the text in the correct order.
val newMessage = message.text.split('\n').map { paragraph: String ->
paragraph.split(' ').map { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val results = parseDirtyWordForKey(word)
if (results?.type == "npub") {
val user = LocalCache.getOrCreateUser(results.keyHex)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
"#[${tagIndex(user)}]${results.restOfWord}"
} else if (results?.type == "note") {
val note = LocalCache.getOrCreateNote(results.keyHex)
"#[${tagIndex(user)}]$restOfWord"
} else if (word.startsWith("@note") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val note = LocalCache.getOrCreateNote(key.toHexKey())
"#[${tagIndex(note)}]$restOfWord"
} else {
word
}
} catch (e: Exception) {
e.printStackTrace()
// if it can't parse the key, don't try to change.
"#[${tagIndex(note)}]${results.restOfWord}"
} else {
word
}
}.joinToString(" ")

View File

@@ -23,6 +23,7 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
@@ -148,7 +149,7 @@ fun ServerConfigHeader() {
}
}
Column(Modifier.weight(1f)) {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(25.dp))
@@ -181,6 +182,16 @@ fun ServerConfigHeader() {
)
Spacer(modifier = Modifier.size(5.dp))
Text(
text = "Spam",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(2.dp))
}
}
}
@@ -290,7 +301,7 @@ fun ServerConfig(
}
}
Column(Modifier.weight(1f)) {
Column(Modifier.weight(1.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
@@ -350,6 +361,21 @@ fun ServerConfig(
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.DeleteSweep,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.spamCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.spamCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -25,7 +24,6 @@ class NewRelayListViewModel: ViewModel() {
fun create(ctx: Context) {
relays.let {
account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
clear(ctx)
@@ -43,8 +41,9 @@ class NewRelayListViewModel: ViewModel() {
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
account.localRelays.map {
@@ -53,8 +52,9 @@ class NewRelayListViewModel: ViewModel() {
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}

View File

@@ -23,9 +23,6 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -64,7 +61,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
postViewModel.create()
onClose()
},
postViewModel.userName.value.isNotBlank()
true
)
}

View File

@@ -9,8 +9,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -18,14 +16,9 @@ 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.Account
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewChannelButton(account: Account) {

View File

@@ -11,11 +11,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.LocalImageLoader
import java.net.URLEncoder
import java.util.Base64
data class ResizeImage(val url: String?, val size: Dp)

View File

@@ -8,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable

View File

@@ -4,12 +4,9 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.text.AnnotatedString
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.ui.note.toShortenHex
@Composable

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -9,7 +8,6 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
@Composable

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -31,6 +32,7 @@ fun ExpandableRichTextViewer(
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
@@ -39,7 +41,7 @@ fun ExpandableRichTextViewer(
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, modifier, tags, accountViewModel, navController)
RichTextViewer(text, canPreview, modifier, tags, backgroundColor, accountViewModel, navController)
if (content.length > 350 && !showFullText) {
Row(
@@ -50,8 +52,8 @@ fun ExpandableRichTextViewer(
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
@@ -61,7 +63,7 @@ fun ExpandableRichTextViewer(
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {

View File

@@ -9,20 +9,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -30,14 +23,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable

View File

@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.launch

View File

@@ -2,27 +2,29 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
@@ -54,6 +56,7 @@ fun RichTextViewer(
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController,
) {
@@ -85,7 +88,7 @@ fun RichTextViewer(
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, accountViewModel, navController)
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -104,7 +107,7 @@ fun RichTextViewer(
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, accountViewModel, navController)
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -158,7 +161,7 @@ fun BechLink(word: String, navController: NavController) {
@Composable
fun TagLink(word: String, tags: List<List<String>>, accountViewModel: AccountViewModel, navController: NavController) {
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
val index = try {
@@ -175,32 +178,48 @@ fun TagLink(word: String, tags: List<List<String>>, accountViewModel: AccountVie
if (index >= 0 && index < tags.size) {
if (tags[index][0] == "p") {
val user = LocalCache.users[tags[index][1]]
if (user != null) {
ClickableUserTag(user, navController)
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1])
if (baseUser != null) {
val userState = baseUser.live().metadata.observeAsState()
val user = userState.value?.user
if (user != null) {
ClickableUserTag(user, navController)
} else {
Text(text = "$word ")
}
} else {
Text(text = "${tags[index][1].toByteArray().toNpub().toShortenHex()} ")
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
} else if (tags[index][0] == "e") {
val note = LocalCache.notes[tags[index][1]]
val note = LocalCache.checkGetOrCreateNote(tags[index][1])
if (note != null) {
//ClickableNoteTag(note, navController)
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f)),
isQuotedNote = true,
navController = navController)
if (canPreview) {
NoteCompose(
baseNote = note,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
.compositeOver(backgroundColor),
isQuotedNote = true,
navController = navController
)
} else {
ClickableNoteTag(note, navController)
}
} else {
Text(text = "${tags[index][1].toByteArray().toNote().toShortenHex()} ")
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
}
} else
Text(text = "$word ")
}
}

View File

@@ -3,15 +3,20 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -20,6 +25,7 @@ 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.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -32,6 +38,8 @@ import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun TranslateableRichTextViewer(
@@ -39,6 +47,7 @@ fun TranslateableRichTextViewer(
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
@@ -55,14 +64,23 @@ fun TranslateableRichTextViewer(
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
withContext(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && content != task.result.result) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
showOriginal = preference == task.result.sourceLang
}
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
@@ -73,8 +91,9 @@ fun TranslateableRichTextViewer(
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
navController,
)
val target = translatedTextState.value.targetLang
@@ -82,7 +101,9 @@ fun TranslateableRichTextViewer(
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
Row(modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
@@ -131,9 +152,56 @@ fun TranslateableRichTextViewer(
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
if (source in account.dontTranslateFrom)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
else
Spacer(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.size(10.dp))
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}) {
if (account.preferenceBetween(source, target) == source)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
else
Spacer(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.size(10.dp))
Text("Show in ${Locale(source).displayName} first")
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}) {
if (account.preferenceBetween(source, target) == target)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
else
Spacer(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.size(10.dp))
Text("Show in ${Locale(target).displayName} first")
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
@@ -142,6 +210,17 @@ fun TranslateableRichTextViewer(
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
if (lang.language in account.translateTo)
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
else
Spacer(modifier = Modifier.size(24.dp))
Spacer(modifier = Modifier.size(10.dp))
Text("Always translate to ${lang.displayName}")
}
}

View File

@@ -11,6 +11,8 @@ import androidx.compose.runtime.setValue
import com.baha.url.preview.IUrlPreviewCallback
import com.baha.url.preview.UrlInfoItem
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
@@ -19,18 +21,20 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
withContext(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
if (urlInfo.allFetchComplete() && urlInfo.url == url)
urlPreviewState = UrlPreviewState.Loaded(urlInfo)
else
urlPreviewState = UrlPreviewState.Empty
}
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
override fun onFailed(throwable: Throwable) {
urlPreviewState = UrlPreviewState.Error("Error parsing preview for ${url}: ${throwable.message}")
}
})
}
}
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->

View File

@@ -16,8 +16,6 @@ import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.vitorpamplona.amethyst.VideoCache
@Composable

View File

@@ -3,18 +3,26 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.*
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.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog

View File

@@ -4,10 +4,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object ChannelFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
@@ -20,6 +16,6 @@ object ChannelFeedFilter: FeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
return channel.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
}
}

View File

@@ -1,17 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import android.util.Log
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
abstract class FeedFilter<T>() {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> {
return feed().take(1000)
val (feed, elapsed) = measureTimedValue {
feed().take(1000)
}
Log.d("Time","${this.javaClass.simpleName} Feed in ${elapsed} with ${feed.size} objects")
return feed
}
abstract fun feed(): List<T>

View File

@@ -4,9 +4,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object GlobalFeedFilter: FeedFilter<Note>() {

View File

@@ -1,11 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object HiddenAccountsFeedFilter: FeedFilter<User>() {
lateinit var account: Account

View File

@@ -16,7 +16,7 @@ object HomeConversationsFeedFilter: FeedFilter<Note>() {
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent)
&& it.author in user.follows
&& account.isAcceptable(it)
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
&& !it.isNewThread()
}
.sortedBy { it.event?.createdAt }

View File

@@ -2,19 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object HomeNewThreadFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
@@ -26,7 +16,7 @@ object HomeNewThreadFeedFilter: FeedFilter<Note>() {
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent)
&& it.author in user.follows
&& account.isAcceptable(it)
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
&& it.isNewThread()
}
.sortedBy { it.event?.createdAt }

View File

@@ -2,25 +2,67 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NotificationFeedFilter: FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile().taggedPosts
.filter { it.author == null || !account.isHidden(it.author!!) }
.filter {
it.author == null
|| (!account.isHidden(it.author!!) && it.author != account.userProfile())
}
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
&& it.event !is LnZapRequestEvent
}
.filter {
it.event !is TextNoteEvent
||
(
it.event is TextNoteEvent
&&
(
it.replyTo?.lastOrNull()?.author == account.userProfile()
||
account.userProfile() in it.directlyCiteUsers()
)
)
}
.filter {
it.event !is ReactionEvent
||
(
it.event is ReactionEvent
&&
(
it.replyTo?.lastOrNull()?.author == account.userProfile()
||
account.userProfile() in it.directlyCiteUsers()
)
)
}
.filter {
it.event !is RepostEvent
||
(
it.event is RepostEvent
&&
(
it.replyTo?.lastOrNull()?.author == account.userProfile()
||
account.userProfile() in it.directlyCiteUsers()
)
)
}
.sortedBy { it.event?.createdAt }
.reversed()
}

View File

@@ -1,9 +1,7 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
object ThreadFeedFilter: FeedFilter<Note>() {
var noteId: String? = null

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
object UserProfileReportsFeedFilter: FeedFilter<Note>() {
var user: User? = null

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.graphics.Rect
import android.view.ViewTreeObserver
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,7 +17,9 @@ import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -27,6 +31,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -44,50 +49,84 @@ val bottomNavigationItems = listOf(
Route.Notification
)
enum class Keyboard {
Opened, Closed
}
@Composable
fun keyboardAsState(): State<Keyboard> {
val keyboardState = remember { mutableStateOf(Keyboard.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
@Composable
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
val currentRoute = currentRoute(navController)
val coroutineScope = rememberCoroutineScope()
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
BottomNavigationItem(
icon = { NotifiableIcon(item, currentRoute, accountViewModel) },
selected = currentRoute == item.route,
onClick = {
coroutineScope.launch {
if (currentRoute != item.route) {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
val isKeyboardOpen by keyboardAsState()
launchSingleTop = true
restoreState = true
if (isKeyboardOpen == Keyboard.Closed) {
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
BottomNavigationItem(
icon = { NotifiableIcon(item, currentRoute, accountViewModel) },
selected = currentRoute == item.route,
onClick = {
coroutineScope.launch {
if (currentRoute != item.route) {
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
launchSingleTop = true
restoreState = true
}
} else {
// TODO: Make it scrool to the top
navController.navigate(item.route){
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start) { inclusive = item.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
}
}
}
)
)
}
}
}
}
@@ -121,6 +160,10 @@ private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel:
hasNewItems = item.hasNewItems(account, notif.cache, context)
}
LaunchedEffect(key1 = db) {
hasNewItems = item.hasNewItems(account, notif.cache, context)
}
if (hasNewItems) {
Box(
Modifier

View File

@@ -57,9 +57,9 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch

View File

@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -8,9 +7,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -36,46 +32,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontWeight.Companion.W500
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import com.google.zxing.qrcode.encoder.Encoder
import com.google.zxing.qrcode.encoder.QRCode
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.ZoomableAsyncImage
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import com.google.zxing.qrcode.encoder.ByteMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@Composable
fun DrawerContent(navController: NavHostController,
@@ -175,20 +150,47 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
}
})
)
Text(
accountUser.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(" @${accountUser.bestUsername()}", color = Color.LightGray)
Row(modifier = Modifier.padding(top = 15.dp)) {
if (accountUser.bestDisplayName() != null) {
Text(
accountUser.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
}),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
if (accountUser.bestUsername() != null) {
Text(" @${accountUser.bestUsername()}", color = Color.LightGray,
modifier = Modifier.padding(top = 15.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
}
Row(modifier = Modifier.padding(top = 15.dp).clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})) {
Row() {
Text("${accountUserFollows.follows?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold)
Text(" Following")
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.followers.size}", fontWeight = FontWeight.Bold)
Text(" Followers")
}
}
@@ -301,7 +303,14 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavCo
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = "v"+BuildConfig.VERSION_NAME,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
/*
IconButton(
onClick = {

View File

@@ -3,8 +3,6 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
@@ -19,16 +17,16 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
sealed class Route(
@@ -109,7 +107,7 @@ private fun homeHasNewItems(account: Account, cache: NotificationCache, context:
HomeNewThreadFeedFilter.account = account
return HomeNewThreadFeedFilter.feed().any {(it.event?.createdAt ?: 0) > lastTime }
return (HomeNewThreadFeedFilter.feed().firstOrNull { it.event?.createdAt != null }?.event?.createdAt ?: 0) > lastTime
}
private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
@@ -117,19 +115,17 @@ private fun notificationHasNewItems(account: Account, cache: NotificationCache,
NotificationFeedFilter.account = account
return NotificationFeedFilter.feed().any {(it.event?.createdAt ?: 0) > lastTime }
return (NotificationFeedFilter.feed().firstOrNull { it.event?.createdAt != null }?.event?.createdAt ?: 0) > lastTime
}
private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
ChatroomListKnownFeedFilter.account = account
return ChatroomListKnownFeedFilter.feed().any {
if (it.channel == null && it.author != account.userProfile()) {
val lastTime = cache.load("Room/${it.author?.pubkeyHex}", context)
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
it.event?.createdAt != null && it.channel == null && it.author != account.userProfile()
} ?: return false
(it.event?.createdAt ?: 0) > lastTime
} else {
false
}
}
val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context)
return (note.event?.createdAt ?: 0) > lastTime
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -46,23 +47,25 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
if (note?.event == null) {
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
LaunchedEffect(key1 = boostSetCard) {
isNew = boostSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt, context)
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt, context)
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
@@ -115,6 +118,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)

View File

@@ -8,12 +8,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
@@ -37,10 +39,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -62,7 +68,16 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
fun ChatroomMessageCompose(
baseNote: Note,
routeForLastRead: String?,
innerQuote: Boolean = false,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
navController: NavController,
onWantsToReply: (Note) -> Unit
) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
@@ -105,6 +120,12 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
shape = ChatBubbleShapeThem
}
if (parentBackgroundColor != null) {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor)
} else {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background)
}
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
@@ -120,18 +141,25 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
}
Column() {
val modif = if (innerQuote) Modifier.padding(top = 10.dp, end = 5.dp) else Modifier.fillMaxWidth(1f).padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
)
Row(
modifier = Modifier.fillMaxWidth(1f).padding(
start = 12.dp,
end = 12.dp,
top = 5.dp,
bottom = 5.dp
),
modifier = modif,
horizontalArrangement = alignment
) {
var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
Row(
horizontalArrangement = alignment,
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
modifier = modif2.onSizeChanged {
availableBubbleSize = it
},
) {
Surface(
@@ -143,8 +171,12 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
onLongClick = { popupExpanded = true }
)
) {
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
Column(
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
modifier = Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged {
bubbleSize = it
},
) {
val authorState by note.author!!.live().metadata.observeAsState()
@@ -194,8 +226,10 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
note,
null,
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
navController = navController
navController = navController,
onWantsToReply = onWantsToReply
)
}
}
@@ -226,6 +260,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
canPreview,
Modifier,
note.event?.tags,
backgroundBubbleColor,
accountViewModel,
navController
)
@@ -235,6 +270,7 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
true,
Modifier,
note.event?.tags,
backgroundBubbleColor,
accountViewModel,
navController
)
@@ -244,20 +280,37 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End,
modifier = Modifier.padding(top = 2.dp)
) {
Text(
timeAgoLong(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(top = 5.dp).then(
with(LocalDensity.current) {
Modifier.widthIn(bubbleSize.width.toDp(), availableBubbleSize.width.toDp())
}
)
) {
Row() {
Text(
timeAgoShort(note.event?.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
RelayBadges(note)
RelayBadges(note)
Spacer(modifier = Modifier.width(10.dp))
}
Row() {
LikeReaction(baseNote, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
ReplyReaction(baseNote, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
}
}
}
}
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
@@ -266,8 +319,6 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
@@ -283,7 +334,10 @@ private fun RelayBadges(baseNote: Note) {
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
Box(
Modifier
.size(15.dp)
.padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
@@ -295,7 +349,7 @@ private fun RelayBadges(baseNote: Note) {
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
.clickable(onClick = { uri.openUri("https://" + url) })
)
}
}

View File

@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@@ -51,18 +52,20 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
LaunchedEffect(key1 = likeSetCard) {
isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
@@ -115,6 +118,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)

View File

@@ -0,0 +1,197 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.Nip05Verifier
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.ui.theme.Nip05
import java.util.Date
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Boolean?> {
var nip05Verified = remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(key1 = user) {
withContext(Dispatchers.IO) {
user.nip05?.ifBlank { null }?.let { nip05 ->
val now = Date().time / 1000
if ((user.nip05LastVerificationTime ?: 0) > (now - 60 * 60)) { // 1hour
nip05Verified.value = user.nip05Verified
} else {
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
user.nip05Verified = true
user.nip05LastVerificationTime = now
nip05Verified.value = true
} else {
user.nip05Verified = false
user.nip05LastVerificationTime = 0
nip05Verified.value = false
}
},
onError = {
user.nip05LastVerificationTime = 0
user.nip05Verified = false
nip05Verified.value = false
}
)
}
}
}
}
return nip05Verified
}
@Composable
fun ObserveDisplayNip05Status(baseNote: Note) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author = note.author
if (author != null)
ObserveDisplayNip05Status(author)
}
@Composable
fun ObserveDisplayNip05Status(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05.split("@")[0] != "_")
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier
.size(14.dp)
.padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
maxLines = 1,
overflow = TextOverflow.Visible
)
}
}
}
}
@Composable
fun DisplayNip05ProfileStatus(user: User) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier.size(16.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier.size(16.dp)
)
}
Text(
text = AnnotatedString(nip05.split("@")[0] + "@"),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}

View File

@@ -1,44 +1,22 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -61,10 +39,12 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlin.time.ExperimentalTime
import nostr.postr.events.TextNoteEvent
@OptIn(ExperimentalFoundationApi::class)
@@ -75,6 +55,9 @@ fun NoteCompose(
modifier: Modifier = Modifier,
isBoostedNote: Boolean = false,
isQuotedNote: Boolean = false,
unPackReply: Boolean = true,
makeItShort: Boolean = false,
parentBackgroundColor: Color? = null,
accountViewModel: AccountViewModel,
navController: NavController
) {
@@ -125,11 +108,22 @@ fun NoteCompose(
}
}
Column(modifier =
modifier.combinedClickable(
var backgroundColor = if (isNew) {
val newColor = MaterialTheme.colors.primary.copy(0.12f)
if (parentBackgroundColor != null) {
newColor.compositeOver(parentBackgroundColor)
} else {
newColor.compositeOver(MaterialTheme.colors.background)
}
} else {
parentBackgroundColor ?: MaterialTheme.colors.background
}
Column(modifier = modifier
.combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
navController.navigate("Note/${note.idHex}") {
launchSingleTop = true
}
} else {
@@ -139,14 +133,10 @@ fun NoteCompose(
}
},
onLongClick = { popupExpanded = true }
).run {
if (isNew) {
this.background(MaterialTheme.colors.primary.copy(0.12f))
} else {
this
}
}
)
.background(backgroundColor)
) {
Row(
modifier = Modifier
.padding(
@@ -228,43 +218,77 @@ fun NoteCompose(
Row(verticalAlignment = Alignment.CenterVertically) {
if (isQuotedNote) {
NoteAuthorPicture(note, navController, account.userProfile(), 25.dp)
}
NoteUsernameDisplay(note, Modifier.weight(1f))
if (noteEvent !is RepostEvent) {
Text(
timeAgo(noteEvent.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
Spacer(Modifier.padding(horizontal = 5.dp))
NoteUsernameDisplay(note, Modifier.weight(1f))
} else {
NoteUsernameDisplay(note, Modifier.weight(1f))
}
if (noteEvent is RepostEvent) {
Text(
" boosted",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Text(
timeAgo(noteEvent.createdAt),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1
)
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { moreActionsExpanded = true }
) {
Icon(
imageVector = Icons.Default.MoreVert,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
NoteDropDownMenu(baseNote, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
}
}
if (note.author != null && !makeItShort) {
ObserveDisplayNip05Status(note.author!!)
}
Spacer(modifier = Modifier.height(3.dp))
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, navController)
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
baseNote = replyingDirectlyTo,
isQuotedNote = true,
modifier = Modifier
.padding(0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
unPackReply = false,
makeItShort = true,
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor),
accountViewModel = accountViewModel,
navController = navController
)
} else {
ReplyInformation(note.replyTo, note.mentions, account, navController)
}
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
val sortedMentions = note.mentions?.toSet()?.sortedBy { account.userProfile().isFollowing(it) }
note.channel?.let {
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
}
}
@@ -274,6 +298,8 @@ fun NoteCompose(
it,
modifier = Modifier,
isBoostedNote = true,
unPackReply = false,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
@@ -295,7 +321,7 @@ fun NoteCompose(
ReportEvent.ReportType.SPAM -> "Spam"
ReportEvent.ReportType.IMPERSONATION -> "Impersonation"
ReportEvent.ReportType.ILLEGAL -> "Illegal Behavior"
else -> "Unkown"
else -> "Unknown"
}
}.joinToString(", ")
@@ -316,15 +342,17 @@ fun NoteCompose(
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
noteEvent.tags,
backgroundColor,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)
if (!makeItShort)
ReactionsRow(note, accountViewModel)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -506,14 +534,12 @@ fun UserPicture(
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Box(
Modifier
.padding(4.dp)
.fillMaxSize()
.clip(CircleShape)
.fillMaxSize(0.6f)
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Icon(
@@ -550,38 +576,53 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text("Broadcast")
}
Divider()
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
Text("Hide User")
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text("Request Deletion")
}
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
DropdownMenuItem(onClick = {
note.author?.let {
accountViewModel.hide(
it,
context
)
}; onDismiss()
}) {
Text("Block & Hide Author")
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Spam / Scam")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Impersonation")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Explicit Content")
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
note.author?.let { accountViewModel.hide(it, context) }
onDismiss()
}) {
Text("Report Illegal Behaviour")
}
}
}
}

View File

@@ -7,6 +7,6 @@ fun ByteArray.toShortenHex(): String {
}
fun String.toShortenHex(): String {
return replaceRange(6, length-6, ":")
return replaceRange(8, length-8, ":")
}

View File

@@ -1,8 +1,8 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@@ -10,10 +10,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -26,7 +25,6 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.outlined.BarChart
@@ -43,9 +41,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
@@ -68,7 +63,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
@@ -79,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import java.math.BigDecimal
import java.math.RoundingMode
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@@ -88,23 +81,6 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val uri = LocalUriHandler.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
}
@@ -119,265 +95,354 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
if (wantsToQuote != null)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account)
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToBoost by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
modifier = Modifier.padding(top = 8.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
wantsToReplyTo = baseNote
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to reply",
Toast.LENGTH_SHORT
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToReplyTo = baseNote
}
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
wantsToBoost = true
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to boost posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
wantsToQuote = baseNote
}
)
}
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
wantsToQuote = baseNote
}
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (account.isWriteable())
accountViewModel.reactTo(baseNote)
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to like Posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
Text(
" ${showCount(reactedNote?.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
Row(
modifier = Modifier
.then(Modifier.size(20.dp))
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
"No Zap Amount Setup. Long Press to change",
Toast.LENGTH_SHORT
)
.show()
}
} else if (!account.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
}
},
onLongClick = {
wantsToChangeZapAmount = true
}
)
) {
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToZap = false
},
onChangeAmount = {
wantsToZap = false
wantsToChangeZapAmount = true
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
showAmount(zappedNote?.zappedAmount()),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.weight(1f)
)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
tint = grayTint
)
}
Row(modifier = Modifier.weight(1f)) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "View count",
modifier = Modifier.height(24.dp),
colorFilter = ColorFilter.tint(grayTint)
)
}
ViewCountReaction(baseNote, Modifier.weight(1f))
}
}
@Composable
fun ReplyReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
showCounter: Boolean = true,
onPress: () -> Unit,
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (accountViewModel.isWriteable())
onPress()
else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to reply",
Toast.LENGTH_SHORT
).show()
}
}
) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
tint = grayTint,
)
}
if (showCounter)
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
)
}
@Composable
private fun BoostReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
onQuotePress: () -> Unit,
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToBoost by remember { mutableStateOf(false) }
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
accountViewModel.deleteBoostsTo(baseNote)
} else {
wantsToBoost = true
}
} else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to be able to boost posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (wantsToBoost) {
BoostTypeChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToBoost = false
},
onQuote = {
wantsToBoost = false
onQuotePress()
}
)
}
if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
)
}
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
}
} else
scope.launch {
Toast.makeText(
context,
"Login with a Private key to like Posts",
Toast.LENGTH_SHORT
).show()
}
}
) {
if (reactedNote?.isReactedBy(accountViewModel.userProfile()) == true) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = Modifier.size(16.dp),
tint = grayTint
)
}
}
Text(
" ${showCount(reactedNote?.reactions?.size)}",
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
)
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZapReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.then(Modifier.size(20.dp))
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = 24.dp),
onClick = {
if (account.zapAmountChoices.isEmpty()) {
scope.launch {
Toast
.makeText(
context,
"No Zap Amount Setup. Long Press to change",
Toast.LENGTH_SHORT
)
.show()
}
} else if (!accountViewModel.isWriteable()) {
scope.launch {
Toast
.makeText(
context,
"Login with a Private key to be able to send Zaps",
Toast.LENGTH_SHORT
)
.show()
}
} else if (account.zapAmountChoices.size == 1) {
accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) {
scope.launch {
Toast
.makeText(context, it, Toast.LENGTH_SHORT)
.show()
}
}
} else if (account.zapAmountChoices.size > 1) {
wantsToZap = true
}
},
onLongClick = {
wantsToChangeZapAmount = true
}
)
) {
if (wantsToZap) {
ZapAmountChoicePopup(
baseNote,
accountViewModel,
onDismiss = {
wantsToZap = false
},
onChangeAmount = {
wantsToZap = false
wantsToChangeZapAmount = true
},
onError = {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
}
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = "Zaps",
modifier = Modifier.size(20.dp),
tint = grayTint
)
}
}
Text(
showAmount(zappedNote?.zappedAmount()),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = textModifier
)
}
@Composable
private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
val uri = LocalUriHandler.current
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
modifier = Modifier.then(Modifier.size(20.dp)),
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
modifier = Modifier.size(19.dp),
tint = grayTint
)
}
Row(modifier = textModifier) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
.crossfade(true)
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = "View count",
modifier = Modifier.height(24.dp),
colorFilter = ColorFilter.tint(grayTint)
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onQuote: () -> Unit) {
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, -50),
@@ -416,9 +481,8 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit) {
fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -429,17 +493,13 @@ private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
onDismissRequest = { onDismiss() }
) {
FlowRow() {
FlowRow(horizontalArrangement = Arrangement.Center) {
account.zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
onDismiss()
},
shape = RoundedCornerShape(20.dp),
@@ -448,16 +508,12 @@ private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
backgroundColor = MaterialTheme.colors.primary
)
) {
Text("${showAmount(amountInSats.toBigDecimal())}",
Text("${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
accountViewModel.zap(baseNote, amountInSats * 1000, "", context) {
scope.launch {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
onDismiss()
},
onLongClick = {
@@ -475,34 +531,46 @@ private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
class UpdateZapAmountViewModel: ViewModel() {
private var account: Account? = null
var amounts by mutableStateOf(TextFieldValue(""))
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
fun load(account: Account) {
this.account = account
this.amounts = TextFieldValue(account.zapAmountChoices.joinToString(", "))
this.amountSet = account.zapAmountChoices
}
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
}
fun updateAmounts(commaSeparatedAmounts: TextFieldValue) {
val correctedText = toListOfAmounts(commaSeparatedAmounts.text).joinToString(", ")
amounts = TextFieldValue(correctedText, commaSeparatedAmounts.selection, commaSeparatedAmounts.composition)
fun addAmount() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null)
amountSet = amountSet + newValue
nextAmount = TextFieldValue("")
}
fun removeAmount(amount: Long) {
amountSet = amountSet - amount
}
fun sendPost() {
account?.changeZapAmounts(toListOfAmounts(amounts.text))
amounts = TextFieldValue("")
account?.changeZapAmounts(amountSet)
nextAmount = TextFieldValue("")
}
fun cancel() {
amounts = TextFieldValue("")
nextAmount = TextFieldValue("")
}
fun hasChanged(): Boolean {
return amountSet != account?.zapAmountChoices
}
}
@OptIn(ExperimentalComposeUiApi::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val postViewModel: UpdateZapAmountViewModel = viewModel()
@@ -510,19 +578,17 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
val ctx = LocalContext.current.applicationContext
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(account) {
postViewModel.load(account)
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onClose() },
properties = DialogProperties(
dismissOnClickOutside = false
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
)
) {
Surface() {
@@ -540,23 +606,48 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
SaveButton(
onPost = {
postViewModel.sendPost()
LocalPreferences(ctx).saveToEncryptedStorage(account)
onClose()
},
isActive = postViewModel.amounts.text.isNotBlank()
isActive = postViewModel.hasChanged()
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
onClick = {
postViewModel.removeAmount(amountInSats)
}
) {
Text("${showAmount(amountInSats.toBigDecimal().setScale(1))}", color = Color.White, textAlign = TextAlign.Center)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
label = { Text(text = "Comma-separated Zap amounts in sats") },
value = postViewModel.amounts,
label = { Text(text = "New Amount in Sats") },
value = postViewModel.nextAmount,
onValueChange = {
postViewModel.updateAmounts(it)
postViewModel.nextAmount = it
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Decimal
keyboardType = KeyboardType.Number
),
placeholder = {
Text(
@@ -565,15 +656,19 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
}
modifier = Modifier.padding(end = 10.dp).weight(1f)
)
Button(
onClick = { postViewModel.addAmount() },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Add", color = Color.White)
}
}
}
}
@@ -581,8 +676,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
}
fun showCount(count: Int?): String {
if (count == null) return " "
if (count == 0) return " "
if (count == null) return ""
if (count == 0) return ""
return when {
count >= 1000000000 -> "${Math.round(count / 1000000000f)}G"
@@ -597,8 +692,8 @@ val OneMega = BigDecimal(1000000)
val OneKilo = BigDecimal(1000)
fun showAmount(amount: BigDecimal?): String {
if (amount == null) return " "
if (amount.abs() < BigDecimal(0.01)) return " "
if (amount == null) return ""
if (amount.abs() < BigDecimal(0.01)) return ""
return when {
amount >= OneGiga -> "%.1fG".format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))

View File

@@ -17,13 +17,11 @@ 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.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.time.Instant
import java.time.ZoneId

View File

@@ -1,9 +1,5 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -11,39 +7,42 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, navController: NavController) {
ReplyInformation(replyTo, mentions) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Account, navController: NavController) {
ReplyInformation(replyTo, mentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) }
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
val repliesToDisplay = if (expanded) mentions else mentions.take(2)
Text(
"replying to ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
val mentionSet = mentions.toSet()
mentionSet.toSet().forEachIndexed { idx, user ->
repliesToDisplay.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
@@ -54,18 +53,46 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, prefix: String
onClick = { onUserTagClick(myUser) }
)
if (idx < mentionSet.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentionSet.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (expanded) {
if (idx < repliesToDisplay.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size - 1) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
} else {
if (idx < repliesToDisplay.size - 1) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size) {
Text(
" and ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
ClickableText(
AnnotatedString("${mentions.size-2}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { expanded = true }
)
Text(
" others",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}

View File

@@ -22,6 +22,22 @@ fun timeAgo(mills: Long?): String {
.replace(" days ago", "d")
}
fun timeAgoShort(mills: Long?): String {
if (mills == null) return " "
var humanReadable = DateUtils.getRelativeTimeSpanString(
mills * 1000,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_ALL
).toString()
if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) {
humanReadable = "now";
}
return humanReadable
}
fun timeAgoLong(mills: Long?): String {
if (mills == null) return " "

View File

@@ -3,31 +3,27 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.FollowButton
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
@@ -38,6 +34,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
val userFollows = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
val coroutineScope = rememberCoroutineScope()
Column(modifier =
Modifier.clickable(
@@ -75,12 +72,11 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (userFollows.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) }
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { account.follow(baseUser) }
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
}
}
}

View File

@@ -29,28 +29,34 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
if (user.bestUsername() != null && user.bestDisplayName() != null) {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else if (user.bestDisplayName() != null) {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else if (user.bestUsername() != null) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.pubkeyDisplayHex(),

View File

@@ -11,6 +11,7 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -19,14 +20,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.screen.FollowButton
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewModel, navController: NavController) {
@@ -45,6 +47,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
val baseAuthor = noteZapRequest.author
val ctx = LocalContext.current.applicationContext
val coroutineScope = rememberCoroutineScope()
if (baseAuthor == null) {
BlankNote()
@@ -102,12 +105,11 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
if (account.isHidden(baseAuthor)) {
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (userFollows.isFollowing(baseAuthor)) {
UnfollowButton { account.unfollow(baseAuthor) }
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
} else {
FollowButton { account.follow(baseAuthor) }
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }
}
}
}

View File

@@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@@ -51,18 +52,20 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = routeForLastRead) {
LaunchedEffect(key1 = zapSetCard) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
val createdAt = note.event?.createdAt
if (createdAt != null)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier.background(
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
).combinedClickable(
modifier = Modifier.background(backgroundColor).combinedClickable(
onClick = {
if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){
@@ -102,7 +105,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it,
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
@@ -115,6 +118,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.ui.qrcode
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.ImageFormat
import android.util.Size
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
@@ -13,21 +10,13 @@ import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode

View File

@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -34,15 +33,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.RoboHashCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import nostr.postr.toNpub
@Composable
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {

View File

@@ -6,8 +6,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
@Composable
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {

View File

@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
@@ -11,6 +8,7 @@ import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -70,9 +68,35 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences): Vie
scope.launch {
ServiceManager.start(account)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
}
}
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) {
localPreferences.saveToEncryptedStorage(it.account)
}
}
fun logOff() {
val state = accountContent.value
when (state) {
is AccountState.LoggedIn -> {
GlobalScope.launch(Dispatchers.Main) {
state.account.saveable.removeObserver(saveListener)
}
}
is AccountState.LoggedInViewOnly -> {
GlobalScope.launch(Dispatchers.Main) {
state.account.saveable.removeObserver(saveListener)
}
}
else -> {}
}
_accountContent.update { AccountState.LoggedOff }
localPreferences.clearEncryptedStorage()

View File

@@ -24,8 +24,8 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
override fun id() = note.idHex + "L" + createdAt
}
class ZapSetCard(val note: Note, val zapEvents: List<Note>): Card() {
val createdAt = zapEvents.maxOf { it.event?.createdAt ?: 0 }
class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>): Card() {
val createdAt = zapEvents.maxOf { it.value.event?.createdAt ?: 0 }
override fun createdAt(): Long {
return createdAt
}

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