Compare commits

..
90 Commits
Author SHA1 Message Date
Vitor Pamplona 21cb82a05d v0.027.2 2023-03-17 14:40:35 -04:00
Vitor Pamplona 412c5c557a Adding the suffix after the hashtag icon 2023-03-17 14:06:00 -04:00
Vitor Pamplona 81e31319c7 Making tags case insensitive 2023-03-17 10:22:58 -04:00
Vitor Pamplona 97ec28d2c8 Additional suffix support for hashtags. 2023-03-17 10:09:08 -04:00
Vitor Pamplona c6906c42fd Mocking Android.Log for test classes 2023-03-17 09:47:47 -04:00
Vitor Pamplona 7921db3ba9 Scaling hashtag images 2023-03-17 09:43:09 -04:00
Vitor Pamplona b12413cbff Removing comments from NIP19 Parser 2023-03-17 09:42:54 -04:00
Vitor Pamplona 07cd1738f1 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:30:33 -04:00
Vitor PamplonaandGitHub b8aa7e64b3 Merge pull request #284 from believethehype/main
Added Special Bitcoin Hashtag with Icon
2023-03-17 09:30:06 -04:00
Vitor Pamplona 055ed62cb7 Merge remote-tracking branch 'origin/HEAD' 2023-03-17 09:29:26 -04:00
Vitor Pamplona 578c70d272 Correctly parsing NIP19s in Text 2023-03-17 09:28:48 -04:00
Vitor PamplonaandGitHub d43f0435d0 Merge pull request #287 from ZsZolee/main
Minor changes
2023-03-17 09:23:12 -04:00
Believethehype 52978fa237 Hashtags in front of a sentence ending should nod work, too. 2023-03-17 13:17:19 +01:00
Believethehype f266a9e964 Special Hashtag for #Nostr 2023-03-17 12:38:47 +01:00
ZoltanandGitHub 01cb27e6f5 Minor changes
Minor changes in the Hungarian translation
2023-03-17 10:12:50 +01:00
Believethehype 38730ea534 fixed some linting issues 2023-03-17 08:03:47 +01:00
believethehypeandGitHub a955f55fc6 Merge branch 'vitorpamplona:main' into main 2023-03-17 07:24:29 +01:00
Believethehype a42cdaaf91 added special Bitcoin Hashtag with Icon
(S/O to tata)
2023-03-16 23:44:25 +01:00
Vitor Pamplona 0633707ec6 Disabling online search after leaving the Search screen 2023-03-16 16:33:17 -04:00
Vitor Pamplona 8768daa06f Fixing Background color of the badge presentation 2023-03-16 16:32:42 -04:00
Vitor Pamplona fb0a7b0ae4 Fixing missing messages on chat 2023-03-16 16:31:42 -04:00
Vitor Pamplona f8b6b8fb45 v0.27.1 2023-03-16 14:24:58 -04:00
Vitor Pamplona 888b6daa2a Support for Following Hashtags 2023-03-16 14:20:30 -04:00
Vitor Pamplona 811c373e4c v0.27.0 2023-03-15 17:07:06 -04:00
Vitor Pamplona 1812ec296e Hashtags 2023-03-15 17:02:49 -04:00
Vitor Pamplona 59cdd81aec Shows LongForm Texts on Global 2023-03-15 17:02:13 -04:00
Vitor Pamplona 9297afc02a Deleting contact lists from pausing the app 2023-03-15 15:13:23 -04:00
Vitor Pamplona a8d8be3307 Removing unused mentions in LocalCache 2023-03-15 14:35:10 -04:00
Vitor Pamplona 53ec6c817c Fixes the deletion of private messages and messages in tags 2023-03-15 14:28:03 -04:00
Vitor Pamplona b7c4ec8358 Shows url host in the link preview 2023-03-15 14:06:26 -04:00
Vitor Pamplona 3426ff307c Makes sure onClick and onLongClick are null when their counterparts are as well. 2023-03-15 13:58:09 -04:00
Vitor Pamplona cddb713b2f Adds the following icon to the chat header 2023-03-15 13:52:26 -04:00
Vitor Pamplona 1871fe6177 Locally cache following Users in the Home & Conversations filter 2023-03-15 13:40:26 -04:00
Vitor Pamplona 83a8558f23 Caching followingChannels and followingUsers to speed Global filter up 2023-03-15 13:39:47 -04:00
Vitor Pamplona dbc155454f Checks for hex keys in the tag list before showing follows 2023-03-15 13:39:06 -04:00
Vitor Pamplona 80e251acfb Avoids creating User for all awardees of a badge. 2023-03-15 13:22:11 -04:00
Vitor Pamplona a597bd7882 Checks the mentions for a valid Hex before creating users in the cache. 2023-03-15 13:14:10 -04:00
Vitor Pamplona a53bb53c12 Avoids breaking urls and invoices when cutting an expandable content 2023-03-15 13:13:41 -04:00
Vitor Pamplona 4fa7aa54a8 Refactoring Note's hasReports function 2023-03-15 13:12:55 -04:00
Vitor Pamplona a9490e1299 Refactoring calls to check if a user is the signed in user. 2023-03-15 13:12:21 -04:00
Vitor Pamplona 2bf36f1cfe Implements a faster AuthorGallery for notifications 2023-03-15 11:39:30 -04:00
Vitor Pamplona c781d5eff7 Forces image size in profile pictures to reduce calculations before resizing the downloaded image. 2023-03-15 11:21:53 -04:00
Vitor Pamplona e8d84795d5 Reduces badge size in Profiles 2023-03-15 11:21:00 -04:00
Vitor Pamplona b351a781a9 Removes time measuring functions from RoboHash 2023-03-15 11:20:36 -04:00
Vitor Pamplona 97d9e96e2b Uses cached functions for isFollowing calculations 2023-03-15 11:20:09 -04:00
Vitor Pamplona 625b8c3bce Makes the reporting Menu less starkey in dark theme 2023-03-15 09:11:55 -04:00
Vitor PamplonaandGitHub edd4a4cc2c Merge pull request #275 from maxmoney21m/feature/216-merge-reports
Add separate screen for report & block
2023-03-15 08:09:07 -04:00
Vitor PamplonaandGitHub b305ccad1d Merge pull request #280 from maxmoney21m/bugfix/gitignore
Remove ignored file
2023-03-15 08:08:39 -04:00
Vitor PamplonaandGitHub 14ca6f1d08 Merge pull request #281 from maxmoney21m/bugfix/string-parameters
Add parameters for strings with variables
2023-03-15 08:08:05 -04:00
maxmoney21m ad4e6d6596 Add Block button and dialog 2023-03-15 13:40:59 +08:00
maxmoney21m e94d2b5340 Update gitignore 2023-03-15 11:29:05 +08:00
maxmoney21m 0241bf0913 Add report & block screen 2023-03-15 11:29:04 +08:00
maxmoney21m 36885eb6dc Add parameters for strings with variables 2023-03-15 11:26:51 +08:00
maxmoney21m c1917855d0 Remove ignored file 2023-03-15 10:42:53 +08:00
Vitor Pamplona 0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona 063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor PamplonaandGitHub 4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona 2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona 8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
Vitor Pamplona f8bb4a3168 v0.26.0 2023-03-14 18:30:48 -04:00
Vitor Pamplona 1b8923b6d7 Speeding up Notification and Global feed filters 2023-03-14 18:30:26 -04:00
Vitor PamplonaandGitHub 675b59c0d2 Merge pull request #233 from vitorpamplona/less_memory_test_branch
Restructuring User/Note
2023-03-14 17:56:38 -04:00
Vitor Pamplona a71078f90f Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-14 17:46:29 -04:00
Vitor Pamplona d4be271060 Fixing typos in the path 2023-03-14 16:27:28 -04:00
Vitor Pamplona 2d59baf898 Uses unique IDs for each job in Actions 2023-03-14 16:14:17 -04:00
Vitor Pamplona 18f651b67f v0.25.3 Testing Build Scripts 2023-03-14 16:03:44 -04:00
Vitor Pamplona f65e09f388 Adding scripts to release all variants of the app 2023-03-14 15:55:08 -04:00
Vitor PamplonaandGitHub a4bb01ef7d Merge pull request #276 from maxmoney21m/feature/54-fdroid-flavor
Make release channel flavors, remove mlkit from fdroid channel
2023-03-14 15:26:11 -04:00
maxmoney21m e9c0d350e1 Make release channel flavors, remove mlkit from fdroid channel 2023-03-15 02:34:50 +08:00
Vitor Pamplona 1c740d4d7a Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
2023-03-13 14:51:44 -04:00
Vitor Pamplona 75ed380449 Fixing merge issue 2023-03-13 14:32:21 -04:00
Vitor Pamplona 01818ff458 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt
2023-03-13 14:09:17 -04:00
Vitor Pamplona 9adb376198 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-10 11:28:13 -05:00
Vitor Pamplona ccf2b06b10 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 15:42:22 -05:00
Vitor Pamplona f2f53c66bd Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 09:01:27 -05:00
Vitor Pamplona b93f28f09d Merge branch 'main' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
2023-03-08 17:47:34 -05:00
Vitor Pamplona 38df1ba6f7 Merge commit '40a6feab8a4a36813644c19ca034333b73e98132' into less_memory_test_branch 2023-03-08 17:35:30 -05:00
Vitor Pamplona 6c9aa9b6ff Merge commit '9c7a5b73b3a75a465f78c1d97726e66f0e3032a3' into less_memory_test_branch 2023-03-08 17:35:21 -05:00
Vitor Pamplona 26b581b13c Merge commit 'a41f2c38ec9683f0e9aaca3e4cffb2cd45f21145' into less_memory_test_branch 2023-03-08 17:34:40 -05:00
Vitor Pamplona 6a3cae1c8d Merge commit 'c7ba0bce5bbc3edd8ece9669751b110d59c40052' into less_memory_test_branch 2023-03-08 17:34:29 -05:00
Vitor Pamplona 40ac31983c Merge commit 'c9b859610e660f7e2d906594410bc7fbf2b1914a' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
2023-03-08 17:34:22 -05:00
Vitor Pamplona 8c25667d0f Organize imports to simplify merge 2023-03-08 17:29:36 -05:00
Vitor Pamplona 6acc842d4f Readding LocalCacheImport 2023-03-08 17:18:25 -05:00
Vitor Pamplona fc4b68b264 Merge commit 'f1b6927bb37d1c9887fb48aa5655a6d2df558e81' into less_memory_test_branch
# Conflicts:
#	app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt
2023-03-08 17:17:03 -05:00
Vitor Pamplona 9470560002 Testing Lint Commit 2023-03-08 17:07:56 -05:00
Vitor Pamplona cd950ee946 Merge commit '2d92e1d44491d62005e45adcf3dc1c5a403c386f' into less_memory_test_branch 2023-03-08 17:04:24 -05:00
Vitor Pamplona f9f68e2a4f Merge commit '691b8225035d8fe59539a971a85b1df2f2f84ebe' into less_memory_test_branch 2023-03-08 17:03:42 -05:00
Vitor Pamplona 3fd656da9f Removes several Indexes from User and Notes to see if we can reduce App's memory usage. 2023-03-08 08:47:33 -05:00
91 changed files with 1966 additions and 860 deletions
+102 -12
View File
@@ -28,10 +28,19 @@ jobs:
- name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
releaseDirectory: app/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -40,10 +49,19 @@ jobs:
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
- name: Sign APK
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
releaseDirectory: app/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -60,24 +78,96 @@ jobs:
draft: false
prerelease: false
- name: Upload APK Asset
id: upload-release-asset-apk
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref_name }}.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload AAB Asset
id: upload-release-asset-aab
- name: Upload Play APK x86 Asset
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/release/app-release.aab
asset_name: app-release-${{ github.ref_name }}.aab
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip
+1
View File
@@ -68,6 +68,7 @@ render.experimental.xml
.idea/**/caches/
.idea/**/libraries/
.idea/**/shelf/
.idea/**/codeStyles
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/.name
+1
View File
@@ -32,6 +32,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
- [x] Online Relay Search (NIP-50)
- [x] Internationalization
- [x] Badges (NIP-58)
- [x] Hashtags
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
+62 -4
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 94
versionName "0.25.2"
versionCode 101
versionName "0.27.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -33,19 +33,45 @@ android {
resValue "string", "app_name", "@string/app_name_debug"
}
}
flavorDimensions "channel"
productFlavors {
play {
dimension "channel"
}
fdroid {
dimension "channel"
}
}
splits {
abi {
enable true
reset()
include "x86", "x86_64"
universalApk true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion "1.4.3"
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
@@ -135,10 +161,10 @@ dependencies {
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
// Local model for language identification
implementation 'com.google.mlkit:language-id:17.0.4'
playImplementation 'com.google.mlkit:language-id:17.0.4'
// Google services model the translate text
implementation 'com.google.mlkit:translate:17.0.1'
playImplementation 'com.google.mlkit:translate:17.0.1'
// Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
@@ -155,3 +181,35 @@ dependencies {
ktlint {
disabledRules.set(["no-wildcard-imports"])
}
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
// NB: Android Studio can't find the imports; this does not affect the
// actual build since Gradle can find them just fine.
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
project.afterEvaluate {
tasks.each { task ->
if (task.name.startsWith("compile") && task.name.endsWith("ReleaseArtProfile")) {
task.doLast {
outputs.files.each { file ->
if (file.name.endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}
}
}
@@ -0,0 +1,26 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) = ExpandableRichTextViewer(
content,
canPreview,
modifier,
tags,
backgroundColor,
accountViewModel,
navController
)
@@ -47,7 +47,8 @@ private object PrefKeys {
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
@@ -183,7 +184,8 @@ object LocalPreferences {
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, account.hideDeleteRequestInfo)
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
}.apply()
@@ -230,7 +232,8 @@ object LocalPreferences {
mapOf()
}
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -241,7 +244,8 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
hideDeleteRequestInfo,
hideDeleteRequestDialog,
hideBlockAlertDialog,
latestContactList
)
}
@@ -289,8 +293,8 @@ object LocalPreferences {
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_INFO,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
@@ -71,6 +71,7 @@ object ServiceManager {
account?.let {
LocalCache.pruneOldAndHiddenMessages(it)
LocalCache.pruneHiddenMessages(it)
LocalCache.pruneContactLists(it)
// LocalCache.pruneNonFollows(it)
}
}
@@ -59,7 +59,8 @@ class Account(
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var hideDeleteRequestInfo: Boolean = false,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var backupContactList: ContactListEvent? = null
) {
var transientHiddenUsers: Set<String> = setOf()
@@ -90,10 +91,12 @@ class Account(
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followsTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
val event = ContactListEvent.create(
follows,
followsTags,
relays,
loggedIn.privKey!!
)
@@ -101,7 +104,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
val event = ContactListEvent.create(listOf(), listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
@@ -154,7 +157,7 @@ class Account(
if (!isWriteable()) return null
note.event?.let {
return LnZapRequestEvent.create(it, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
return null
@@ -167,10 +170,10 @@ class Account(
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
if (!isWriteable()) return null
return LnZapRequestEvent.create(userPubKeyHex, userProfile().relays?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
}
fun report(note: Note, type: ReportEvent.ReportType) {
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
if (!isWriteable()) return
if (note.hasReacted(userProfile(), "⚠️")) {
@@ -185,7 +188,7 @@ class Account(
}
note.event?.let {
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
val event = ReportEvent.create(it, type, loggedIn.privKey!!, content = content)
Client.send(event)
LocalCache.consume(event, null)
}
@@ -245,18 +248,49 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null && follows.isNotEmpty()) {
val event = if (contactList != null) {
ContactListEvent.create(
follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays,
followingUsers.plus(Contact(user.pubkeyHex, null)),
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
listOf(Contact(user.pubkeyHex, null)),
followingTags,
relays,
loggedIn.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
fun follow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
val event = if (contactList != null) {
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
contactList.relays(),
loggedIn.privKey!!
)
} else {
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
ContactListEvent.create(
followingUsers,
followingTags.plus(tag),
relays,
loggedIn.privKey!!
)
@@ -270,12 +304,34 @@ class Account(
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val follows = contactList?.follows() ?: emptyList()
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && follows.isNotEmpty()) {
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays,
followingUsers.filter { it.pubKeyHex != user.pubkeyHex },
followingTags,
contactList.relays(),
loggedIn.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
fun unfollow(tag: String) {
if (!isWriteable()) return
val contactList = userProfile().latestContactList
val followingUsers = contactList?.follows() ?: emptyList()
val followingTags = contactList?.unverifiedFollowTagSet() ?: emptyList()
if (contactList != null && (followingUsers.isNotEmpty() || followingTags.isNotEmpty())) {
val event = ContactListEvent.create(
followingUsers,
followingTags.filter { !it.equals(tag, ignoreCase = true) },
contactList.relays(),
loggedIn.privKey!!
)
@@ -454,7 +510,7 @@ class Account(
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if (newContactList?.follows().isNullOrEmpty()) return
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
// Events might be different objects, we have to compare their ids.
if (backupContactList?.id != newContactList?.id) {
@@ -465,7 +521,7 @@ class Account(
// Takes a User's relay list and adds the types of feeds they are active for.
fun activeRelays(): Array<Relay>? {
var usersRelayList = userProfile().relays?.map {
var usersRelayList = userProfile().latestContactList?.relays()?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
} ?: return null
@@ -501,15 +557,27 @@ class Account(
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
fun followingKeySet(): Set<HexKey> {
return userProfile().cachedFollowingKeySet() ?: emptySet()
}
fun followingTagSet(): Set<HexKey> {
return userProfile().cachedFollowingTagSet() ?: emptySet()
}
fun isAcceptable(user: User): Boolean {
return !isHidden(user) && // if user hasn't hided this author
user.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
user.countReportAuthorsBy(userProfile().follows) < 5
user.countReportAuthorsBy(followingKeySet()) < 5
}
fun isAcceptableDirect(note: Note): Boolean {
return note.reportsBy(userProfile()).isEmpty() && // if user has not reported this post
note.countReportAuthorsBy(userProfile().follows) < 5 // if it has 5 reports by reliable users
note.countReportAuthorsBy(followingKeySet()) < 5 // if it has 5 reports by reliable users
}
fun isFollowing(user: User): Boolean {
return user.pubkeyHex in followingKeySet()
}
fun isAcceptable(note: Note): Boolean {
@@ -522,7 +590,7 @@ class Account(
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val followsPlusMe = userProfile().latestContactList?.verifiedFollowKeySetAndMe ?: emptySet()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
@@ -532,8 +600,9 @@ class Account(
return (
note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports
(
note.author?.reportsBy(followsPlusMe) ?: emptyList()
) + innerReports
).toSet()
}
@@ -544,8 +613,13 @@ class Account(
saveable.invalidateData()
}
fun setHideDeleteRequestInfo() {
hideDeleteRequestInfo = true
fun setHideDeleteRequestDialog() {
hideDeleteRequestDialog = true
saveable.invalidateData()
}
fun setHideBlockAlertDialog() {
hideBlockAlertDialog = true
saveable.invalidateData()
}
@@ -575,7 +649,7 @@ class Account(
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) {
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
}
}
@@ -27,6 +27,10 @@ class Channel(val idHex: String) {
notes[note.idHex] = note
}
fun removeNote(note: Note) {
notes.remove(note.idHex)
}
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
this.creator = creator
this.info = channelInfo
@@ -0,0 +1,17 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
if (tag.lowercase() == "bitcoin" || tag.lowercase() == "btc") {
return HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color(0xFFF2A900))
} else if (tag.lowercase() == "nostr") {
return HashtagIcon(R.drawable.ht_nostr, "Nostr", Color(0xFF9C59FF))
}
return null
}
class HashtagIcon(
val icon: Int,
val description: String,
val color: Color
)
@@ -4,8 +4,27 @@ import android.util.Log
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.model.ATag
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.DeletionEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.MetadataEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.Relay
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
@@ -162,24 +181,15 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
// Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -207,22 +217,13 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = tagsWithoutCitations(event).mapNotNull { checkGetOrCreateNote(it) }
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
refreshObservers()
}
}
@@ -235,7 +236,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList<User>(), emptyList<Note>())
note.loadEvent(event, author, emptyList<Note>())
refreshObservers()
}
@@ -252,7 +253,7 @@ object LocalCache {
event.badgeAwardDefinitions().mapNotNull { getOrCreateAddressableNote(it) }
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList(), replyTo)
note.loadEvent(event, author, replyTo)
author.updateAcceptedBadges(note)
@@ -269,15 +270,9 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardees = event.awardees().mapNotNull { checkGetOrCreateUser(it) }
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, awardees, awardDefinition)
// Adds notifications to users.
awardees.forEach {
it.addTaggedPost(note)
}
note.loadEvent(event, author, awardDefinition)
// Replies of an Badge Definition are Award Events
awardDefinition.forEach {
@@ -287,53 +282,6 @@ object LocalCache {
refreshObservers()
}
private fun findCitations(event: Event): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(event.content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event.tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
private fun tagsWithoutCitations(event: TextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
private fun tagsWithoutCitations(event: LongTextNoteEvent): List<String> {
val repliesTo = event.replyTos()
val tagAddresses = event.taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations(event)
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
@Suppress("UNUSED_PARAMETER")
fun consume(event: RecommendRelayEvent) {
// // Log.d("RR", event.toJson())
@@ -341,41 +289,11 @@ object LocalCache {
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val follows = event.follows()
val follows = event.unverifiedFollowKeySet()
if (event.createdAt > user.updatedFollowsAt && !follows.isNullOrEmpty()) {
if (event.createdAt > (user.latestContactList?.createdAt ?: 0) && !follows.isNullOrEmpty()) {
// Saves relay list only if it's a user that is currently been seen
user.latestContactList = event
user.updateFollows(
follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
Log.w("ContactList Parser", "Ignoring: Could not parse Hex key: ${it.pubKeyHex} in ${event.toJson()}")
// e.printStackTrace()
null
}
}.filterNotNull().toSet(),
event.createdAt
)
// Saves relay list only if it's a user that is currently been seen
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) {
Log.w("Relay List Parser", "Relay import issue ${e.message}", e)
e.printStackTrace()
}
user.updateContactList(event)
// Log.d("CL", "AAA ${user.toBestDisplayName()} ${follows.size}")
}
@@ -397,10 +315,10 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
.mapNotNull { checkGetOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (recipient != null) {
author.addMessage(recipient, note)
@@ -419,13 +337,11 @@ object LocalCache {
deleteNote.author?.removeNote(deleteNote)
// reverts the add
deleteNote.mentions?.forEach { user ->
user.removeTaggedPost(deleteNote)
user.removeReport(deleteNote)
}
val mentions = deleteNote.event?.tags()?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }?.mapNotNull { checkGetOrCreateUser(it) }
deleteNote.replyTo?.forEach { replyingNote ->
replyingNote.author?.removeTaggedPost(deleteNote)
mentions?.forEach { user ->
user.removeReport(deleteNote)
}
// Counts the replies
@@ -437,6 +353,19 @@ object LocalCache {
masterNote.removeReport(deleteNote)
}
val channel = deleteNote.channel()
channel?.removeNote(deleteNote)
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
notes.remove(deleteNote.idHex)
deletedAtLeastOne = true
@@ -457,23 +386,14 @@ object LocalCache {
// Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Prepares user's profile view.
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
repliesTo.forEach {
it.addBoost(note)
@@ -489,22 +409,13 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
if (
event.content == "" ||
event.content == "+" ||
@@ -544,7 +455,7 @@ object LocalCache {
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
@@ -570,7 +481,7 @@ object LocalCache {
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
note.loadEvent(event, author, emptyList())
refreshObservers()
}
@@ -590,7 +501,7 @@ object LocalCache {
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList(), emptyList())
note.loadEvent(event, author, emptyList())
refreshObservers()
}
@@ -626,23 +537,14 @@ object LocalCache {
return
}
val mentions = event.mentions().mapNotNull { checkGetOrCreateUser(it) }
val replyTo = event.replyTos()
.mapNotNull { checkGetOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.loadEvent(event, author, mentions, replyTo)
note.loadEvent(event, author, replyTo)
// Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
replyTo.forEach {
it.author?.addTaggedPost(note)
}
// Counts the replies
replyTo.forEach {
it.addReply(note)
@@ -671,9 +573,12 @@ object LocalCache {
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
((zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>())
(
(zapRequest?.event as? LnZapRequestEvent)?.taggedAddresses()
?.map { getOrCreateAddressableNote(it) } ?: emptySet<Note>()
)
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
if (zapRequest == null) {
Log.e("ZP", "Zap Request not found. Unable to process Zap {${event.toJson()}}")
@@ -682,14 +587,6 @@ object LocalCache {
// Log.d("ZP", "New ZapEvent ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(zapRequest, note)
}
@@ -709,18 +606,10 @@ object LocalCache {
val repliesTo = event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
note.loadEvent(event, author, repliesTo)
// Log.d("ZP", "New Zap Request ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addTaggedPost(note)
}
repliesTo.forEach {
it.author?.addTaggedPost(note)
}
repliesTo.forEach {
it.addZap(note, null)
}
@@ -778,12 +667,9 @@ object LocalCache {
// 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)
}
val mentions =
it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
// Counts the replies
it.replyTo?.forEach { _ ->
@@ -795,40 +681,6 @@ object LocalCache {
}
}
fun pruneNonFollows(account: Account) {
val follows = account.userProfile().follows
val knownPMs = account.userProfile().privateChatrooms.filter {
account.userProfile().hasSentMessagesTo(it.key) && account.isAcceptable(it.key)
}
val followsFollow = follows.map {
it.follows
}.flatten()
val followSet = follows.plus(knownPMs).plus(account.userProfile()).plus(followsFollow)
val toBeRemoved = notes
.filter {
(it.value.author == null || it.value.author!! !in followSet) && it.value.event?.kind() == TextNoteEvent.kind && it.value.liveSet?.isInUse() != true
}
toBeRemoved.forEach {
notes.remove(it.key)
}
val toBeRemovedUsers = users
.filter {
(it.value !in followSet) && it.value.liveSet?.isInUse() != true
}
toBeRemovedUsers.forEach {
users.remove(it.key)
}
println("PRUNE: ${toBeRemoved.size} messages removed because they came from NonFollows")
println("PRUNE: ${toBeRemovedUsers.size} users removed because are NonFollows")
}
fun pruneHiddenMessages(account: Account) {
val toBeRemoved = account.hiddenUsers.map {
(users[it]?.notes ?: emptySet())
@@ -841,14 +693,6 @@ 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)
@@ -864,6 +708,18 @@ object LocalCache {
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
fun pruneContactLists(userAccount: Account) {
var removingContactList = 0
users.values.forEach {
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false)) {
it.latestContactList = null
removingContactList++
}
}
println("PRUNE: $removingContactList contact lists")
}
// Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this)
@@ -872,7 +728,8 @@ object LocalCache {
}
}
class LocalCacheLiveData(val cache: LocalCache) : LiveData<LocalCacheState>(LocalCacheState(cache)) {
class LocalCacheLiveData(val cache: LocalCache) :
LiveData<LocalCacheState>(LocalCacheState(cache)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@@ -35,7 +35,6 @@ open class Note(val idHex: String) {
// They are immutable after that.
var event: EventInterface? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
@@ -72,10 +71,9 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
fun loadEvent(event: Event, author: User, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
@@ -215,15 +213,15 @@ open class Note(val idHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -241,9 +239,9 @@ open class Note(val idHex: String) {
val dayAgo = Date().time / 1000 - 24 * 60 * 60
return reports.isNotEmpty() ||
(
author?.reports?.values?.filter {
author?.reports?.values?.any {
it.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null
}?.isNotEmpty() ?: false
} ?: false
)
}
@@ -24,25 +24,14 @@ import java.util.regex.Pattern
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
class Badges(val definition: Note, val awardees: Set<Note>)
class User(val pubkeyHex: String) {
var info: UserMetadata? = null
var updatedFollowsAt: Long = 0
var latestContactList: ContactListEvent? = null
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = mapOf<User, Set<Note>>()
private set
@@ -51,9 +40,6 @@ class User(val pubkeyHex: String) {
var zaps = mapOf<Note, Note?>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
@@ -89,54 +75,25 @@ class User(val pubkeyHex: String) {
return info?.picture
}
fun follow(user: User) {
follows = follows + user
user.followers = user.followers + this
fun updateContactList(event: ContactListEvent) {
if (event.id == latestContactList?.id) return
val oldContactListEvent = latestContactList
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun unfollow(user: User) {
follows = follows - user
user.followers = user.followers - this
liveSet?.follows?.invalidateData()
user.liveSet?.follows?.invalidateData()
}
fun follow(users: Set<User>) {
follows = follows + users
users.forEach {
if (this !in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers + this
it.liveSet?.follows?.invalidateData()
}
// Update Followers of the past user list
// Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.follows?.invalidateData()
}
liveSet?.follows?.invalidateData()
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
if (this in it.followers && it.liveSet?.isInUse() == true) {
it.followers = it.followers - this
it.liveSet?.follows?.invalidateData()
}
}
liveSet?.follows?.invalidateData()
}
fun addTaggedPost(note: Note) {
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
// No need for Listener yet
}
}
fun removeTaggedPost(note: Note) {
taggedPosts = taggedPosts - note
liveSet?.relays?.invalidateData()
}
fun addNote(note: Note) {
@@ -209,15 +166,15 @@ class User(val pubkeyHex: String) {
return reports[user] ?: emptySet()
}
fun reportAuthorsBy(users: Set<User>): List<User> {
return reports.keys.filter { it in users }
fun reportAuthorsBy(users: Set<HexKey>): List<User> {
return reports.keys.filter { it.pubkeyHex in users }
}
fun countReportAuthorsBy(users: Set<User>): Int {
return reports.keys.count { it in users }
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.keys.count { it.pubkeyHex in users }
}
fun reportsBy(users: Set<User>): List<Note> {
fun reportsBy(users: Set<HexKey>): List<Note> {
return reportAuthorsBy(users).mapNotNull {
reports[it]
}.flatten()
@@ -232,6 +189,7 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun addMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
@@ -240,6 +198,15 @@ class User(val pubkeyHex: String) {
}
}
@Synchronized
fun removeMessage(user: User, msg: Note) {
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
liveSet?.messages?.invalidateData()
}
}
fun addRelayBeingUsed(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed[relay.url]
if (here == null) {
@@ -254,22 +221,6 @@ class User(val pubkeyHex: String) {
liveSet?.relayInfo?.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
follow(toBeAdded)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
// no need to test if relays are different. The Account will check for us.
relays = relayUse
liveSet?.relays?.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) {
info = newUserInfo
info?.latestMetadata = latestMetadata
@@ -295,7 +246,51 @@ class User(val pubkeyHex: String) {
}
fun isFollowing(user: User): Boolean {
return follows.contains(user)
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
return user.pubkeyHex in it
} ?: false
}
fun isFollowingHashtag(tag: String): Boolean {
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
return tag in it
} ?: false
}
fun isFollowingHashtagCached(tag: String): Boolean {
return latestContactList?.verifiedFollowTagSet?.let {
return tag.lowercase() in it
} ?: false
}
fun isFollowingCached(user: User): Boolean {
return latestContactList?.verifiedFollowKeySet?.let {
return user.pubkeyHex in it
} ?: false
}
fun transientFollowCount(): Int? {
return latestContactList?.unverifiedFollowKeySet()?.size
}
fun transientFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun cachedFollowingKeySet(): Set<HexKey> {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}
fun cachedFollowCount(): Int? {
return latestContactList?.verifiedFollowKeySet?.size
}
fun cachedFollowerCount(): Int {
return LocalCache.users.values.count { it.latestContactList?.let { pubkeyHex in it.unverifiedFollowKeySet() } ?: false }
}
fun hasSentMessagesTo(user: User?): Boolean {
@@ -0,0 +1,38 @@
package com.vitorpamplona.amethyst.service
import androidx.compose.ui.text.capitalize
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") {
private var hashtagToWatch: String? = null
fun createLoadHashtagFilter(): TypedFilter? {
val hashToLoad = hashtagToWatch ?: return null
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("t" to listOf(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
)
}
val loadHashtagChannel = requestNewChannel()
override fun updateChannelFilters() {
loadHashtagChannel.typedFilters = listOfNotNull(createLoadHashtagFilter()).ifEmpty { null }
}
fun loadHashtag(tag: String?) {
hashtagToWatch = tag
invalidateFilters()
}
}
@@ -40,10 +40,10 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val follows = account.followingKeySet()
val followKeys = follows.map {
it.pubkeyHex.substring(0, 6)
it.substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
@@ -58,9 +58,28 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
)
}
fun createFollowTagsFilter(): TypedFilter? {
val hashToLoad = account.followingTagSet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
}.flatten()
),
limit = 100
)
)
}
val followAccountChannel = requestNewChannel()
override fun updateChannelFilters() {
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
followAccountChannel.typedFilters = listOfNotNull(createFollowAccountsFilter(), createFollowTagsFilter()).ifEmpty { null }
}
}
@@ -18,7 +18,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
val mySearchString = searchString
if (mySearchString == null) {
if (mySearchString.isNullOrBlank()) {
return null
}
@@ -0,0 +1,57 @@
package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.tagSearch
open class BaseTextNoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = taggedUsers()
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun findCitations(): Set<String> {
var citations = mutableSetOf<String>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
return citations
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses = taggedAddresses().map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
}
@@ -14,7 +14,7 @@ class ChannelCreateEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun channelInfo() = try {
fun channelInfo(): ChannelData = try {
MetadataEvent.gson.fromJson(content, ChannelData::class.java)
} catch (e: Exception) {
Log.e("ChannelMetadataEvent", "Can't parse channel info $content", e)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -17,28 +18,60 @@ class ContactListEvent(
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun follows() = try {
tags.filter { it[0] == "p" }.map { Contact(it[1], it.getOrNull(2)) }
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse tags as follows: $tags", e)
null
// This function is only used by the user logged in
// But it is used all the time.
val verifiedFollowKeySet: Set<HexKey> by lazy {
tags.filter { it[0] == "p" }.mapNotNull {
it.getOrNull(1)?.let { unverifiedHex: String ->
try {
decodePublicKey(unverifiedHex).toHexKey()
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
}.toSet()
}
fun relayUse() = try {
val verifiedFollowTagSet: Set<String> by lazy {
unverifiedFollowTagSet().map { it.lowercase() }.toSet()
}
val verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
verifiedFollowKeySet + pubKey
}
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.mapNotNull { it.getOrNull(1) }
fun unverifiedFollowTagSet() = tags.filter { it[0] == "t" }.mapNotNull { it.getOrNull(1) }
fun follows() = tags.filter { it[0] == "p" }.mapNotNull {
try {
Contact(decodePublicKey(it[1]).toHexKey(), it.getOrNull(2))
} catch (e: Exception) {
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
null
}
}
fun followsTags() = tags.filter { it[0] == "t" }.mapNotNull {
it.getOrNull(2)
}
fun relays(): Map<String, ReadWrite>? = try {
if (content.isNotEmpty()) {
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type)
gson.fromJson(content, object : TypeToken<Map<String, ReadWrite>>() {}.type) as Map<String, ReadWrite>
} else {
null
}
} catch (e: Exception) {
Log.e("ContactListEvent", "can't parse content as relay lists: $tags", e)
Log.w("ContactListEvent", "Can't parse content as relay lists: $content", e)
null
}
companion object {
const val kind = 3
fun create(follows: List<Contact>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
fun create(follows: List<Contact>, followTags: List<String>, relayUse: Map<String, ReadWrite>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ContactListEvent {
val content = if (relayUse != null) {
gson.toJson(relayUse)
} else {
@@ -51,7 +84,10 @@ class ContactListEvent(
} else {
listOf("p", it.pubKeyHex)
}
} + followTags.map {
listOf("t", it)
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return ContactListEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
@@ -37,6 +37,16 @@ open class Event(
override fun toJson(): String = gson.toJson(this)
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
override fun isTaggedHash(hashtag: String) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1).equals(hashtag, true) }
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
/**
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
*/
@@ -22,4 +22,10 @@ interface EventInterface {
fun checkSignature()
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
fun isTaggedHash(hashtag: String): Boolean
fun isTaggedHashes(hashtag: Set<String>): Boolean
fun firstIsTaggedHashes(hashtag: Set<String>): String?
}
@@ -25,7 +25,12 @@ class LnZapRequestEvent(
companion object {
const val kind = 9734
fun create(originalNote: EventInterface, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
fun create(
originalNote: EventInterface,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
@@ -42,7 +47,12 @@ class LnZapRequestEvent(
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
fun create(userHex: String, relays: Set<String>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): LnZapRequestEvent {
fun create(
userHex: String,
relays: Set<String>,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
val content = ""
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = listOf(
@@ -12,16 +12,7 @@ class LongTextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
fun address() = ATag(kind, pubKey, dTag(), null)
@@ -57,9 +57,13 @@ class ReportEvent(
companion object {
const val kind = 1984
fun create(reportedPost: EventInterface, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
val content = ""
fun create(
reportedPost: EventInterface,
type: ReportType,
privateKey: ByteArray,
content: String = "",
createdAt: Long = Date().time / 1000
): ReportEvent {
val reportPostTag = listOf("e", reportedPost.id(), type.name.lowercase())
val reportAuthorTag = listOf("p", reportedPost.pubKey(), type.name.lowercase())
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.model
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
import nostr.postr.Utils
import java.util.Date
@@ -12,16 +13,7 @@ class TextNoteEvent(
tags: List<List<String>>,
content: String,
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
fun mentions() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
val aTagValue = it.getOrNull(1)
val relay = it.getOrNull(2)
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
}
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 1
@@ -38,6 +30,9 @@ class TextNoteEvent(
addresses?.forEach {
tags.add(listOf("a", it.toTag()))
}
findHashtags(msg).forEach {
tags.add(listOf("t", it))
}
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
@@ -1,35 +1,43 @@
package com.vitorpamplona.amethyst.service.nip19
import android.util.Log
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.bechToBytes
import java.util.regex.Pattern
object Nip19 {
enum class Type {
USER, NOTE, RELAY, ADDRESS
}
data class Return(val type: Type, val hex: String, val relay: String? = null)
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "")
fun uriToRoute(uri: String?): Return? {
try {
val key = uri?.removePrefix("nostr:") ?: return null
if (uri == null) return null
val bytes = key.bechToBytes()
if (key.startsWith("npub")) {
return npub(bytes)
} else if (key.startsWith("note")) {
return note(bytes)
} else if (key.startsWith("nprofile")) {
return nprofile(bytes)
} else if (key.startsWith("nevent")) {
return nevent(bytes)
} else if (key.startsWith("nrelay")) {
return nrelay(bytes)
} else if (key.startsWith("naddr")) {
return naddr(bytes)
try {
val matcher = nip19regex.matcher(uri)
matcher.find()
val uriScheme = matcher.group(1) // nostr:
val type = matcher.group(2) // npub1
val key = matcher.group(3) // bech32
val additionalChars = matcher.group(4) ?: "" // additional chars
val bytes = (type + key).bechToBytes()
val parsed = when (type.lowercase()) {
"npub1" -> npub(bytes)
"note1" -> note(bytes)
"nprofile1" -> nprofile(bytes)
"nevent1" -> nevent(bytes)
"nrelay1" -> nrelay(bytes)
"naddr1" -> naddr(bytes)
else -> null
}
return parsed?.copy(additionalChars = additionalChars)
} catch (e: Throwable) {
println("Issue trying to Decode NIP19 $uri: ${e.message}")
Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e)
}
return null
@@ -10,6 +10,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
@@ -36,7 +37,10 @@ class NewPostViewModel : ViewModel() {
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
val currentMentions = (replyNote.event as? TextNoteEvent)
?.mentions()
?.map { LocalCache.getOrCreateUser(it) } ?: emptyList()
if (currentMentions.contains(replyUser)) {
this.mentions = currentMentions
} else {
@@ -32,7 +32,7 @@ class NewRelayListViewModel : ViewModel() {
fun clear() {
_relays.update {
var relayFile = account.userProfile().relays
var relayFile = account.userProfile().latestContactList?.relays()
// Ugly, but forces nostr.band as the only search-supporting relay today.
// TODO: Remove when search becomes more available.
@@ -7,7 +7,8 @@ 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.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -24,58 +25,55 @@ fun ClickableRoute(
val userState by userBase.live().metadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
val text = user.toBestDisplayName()
ClickableText(
text = AnnotatedString("@$text "),
onClick = { navController.navigate(route) },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
} else if (nip19.type == Nip19.Type.ADDRESS) {
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
if (noteBase == null) {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
} else {
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else if (nip19.type == Nip19.Type.NOTE) {
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
val noteState by noteBase.live().metadata.observeAsState()
val note = noteState?.note ?: return
val channel = note.channel()
if (note.event is ChannelCreateEvent) {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else if (note.channel() != null) {
ClickableText(
text = AnnotatedString("@${note.channel()?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel()?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
} else if (channel != null) {
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
} else {
ClickableText(
text = AnnotatedString("@${note.idDisplayNote()} "),
onClick = { navController.navigate("Note/${nip19.hex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
}
} else {
Text(
"@${nip19.hex} "
"@${nip19.hex}${nip19.additionalChars} "
)
}
}
@Composable
fun CreateClickableText(clickablePart: String, suffix: String, route: String, navController: NavController) {
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("@$clickablePart")
}
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.onBackground).toSpanStyle()
) {
append("$suffix ")
}
},
onClick = { navController.navigate(route) }
)
}
@@ -26,7 +26,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
const val SHORT_TEXT_LENGTH = 350
@@ -43,15 +42,14 @@ fun ExpandableRichTextViewer(
) {
var showFullText by remember { mutableStateOf(false) }
// Cuts the text in the first space after 350
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH)
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut
val text = if (showFullText) {
content
} else {
val (lnStart, lnEnd) = LnInvoiceUtil.locateInvoice(content)
if (lnStart < SHORT_TEXT_LENGTH && lnEnd > 0) {
content.take(lnEnd)
} else {
content.take(SHORT_TEXT_LENGTH)
}
content.take(whereToCut)
}
Box(contentAlignment = Alignment.BottomCenter) {
@@ -67,7 +65,7 @@ fun ExpandableRichTextViewer(
)
// }
if (content.length > 350 && !showFullText) {
if (content.length > whereToCut && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
@@ -1,13 +1,14 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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
@@ -17,11 +18,14 @@ 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.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -32,6 +36,7 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -41,13 +46,13 @@ import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([A-Za-z0-9_-]+)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -144,6 +149,8 @@ fun RichTextViewer(
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -163,6 +170,8 @@ fun RichTextViewer(
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, accountViewModel, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
@@ -191,19 +200,7 @@ fun isBechLink(word: String): Boolean {
@Composable
fun BechLink(word: String, navController: NavController) {
val uri = if (word.startsWith("nostr", true)) {
word
} else if (word.startsWith("@")) {
word.replaceFirst("@", "nostr:")
} else {
"nostr:$word"
}
val nip19Route = try {
Nip19.uriToRoute(uri)
} catch (e: Exception) {
null
}
val nip19Route = Nip19.uriToRoute(word)
if (nip19Route == null) {
Text(text = "$word ")
@@ -212,6 +209,47 @@ fun BechLink(word: String, navController: NavController) {
}
}
@Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (tag, suffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (tag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag)
ClickableText(
text = buildAnnotatedString {
withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) {
append("#$tag")
}
},
onClick = { navController.navigate("Hashtag/$tag") }
)
if (hashtagIcon != null) {
Icon(
painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description,
tint = hashtagIcon.color,
modifier = Modifier.size(20.dp).padding(0.dp, 5.dp, 0.dp, 0.dp)
)
}
Text(text = "$suffix ")
} else {
Text(text = "$word ")
}
}
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word)
@@ -13,8 +13,6 @@ import coil.request.ImageRequest
import coil.request.Options
import okio.Buffer
import java.security.MessageDigest
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
private fun toHex(color: Color): String {
val argb = color.toArgb()
@@ -66,26 +64,20 @@ class HashImageFetcher(
private val data: Uri
) : Fetcher {
@OptIn(ExperimentalTime::class)
override suspend fun fetch(): SourceResult {
val (value, elapsed) = measureTimedValue {
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
}
println("Elapsed: $elapsed")
return value
return SourceResult(
source = ImageSource(source, context),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
class Factory : Fetcher.Factory<Uri> {
object Factory : Fetcher.Factory<Uri> {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
return HashImageFetcher(options.context, data)
}
@@ -96,7 +88,7 @@ object Robohash {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory())
.fetcherFactory(HashImageFetcher.Factory)
.crossfade(100)
.build()
}
@@ -0,0 +1,101 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Composable
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
val focusRequester = remember { FocusRequester() }
val interactionSource = remember { MutableInteractionSource() }
var optionsShowing by remember { mutableStateOf(false) }
var currentText by remember { mutableStateOf(placeholder) }
Box(
modifier = modifier
) {
OutlinedTextField(
value = currentText,
onValueChange = {},
readOnly = true,
label = { Text(label) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Box(
modifier = Modifier
.matchParentSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
optionsShowing = true
focusRequester.requestFocus()
}
)
}
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
}
}
}
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
shape = RoundedCornerShape(5.dp)
) {
LazyColumn() {
itemsIndexed(options) { index, item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 16.dp)
.clickable {
onSelect(index)
}
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
}
}
}
}
}
}
@@ -34,12 +34,18 @@ fun UrlPreviewCard(
modifier = Modifier
.clickable { runCatching { uri.openUri(url) } }
.clip(shape = RoundedCornerShape(15.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
) {
Column {
val url = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
URL(URL(previewInfo.url), previewInfo.image).toString()
URL(url, previewInfo.image).toString()
} else {
previewInfo.image
}
@@ -51,12 +57,23 @@ fun UrlPreviewCard(
modifier = Modifier.fillMaxWidth()
)
Text(
text = url.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = previewInfo.title,
style = MaterialTheme.typography.body2,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -10,18 +10,41 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object GlobalFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed() = LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in account.followingChannels()) &&
// does not show people the user already follows
(it.author !in account.userProfile().follows)
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.reversed()
override fun feed(): List<Note> {
val followChannels = account.followingChannels()
val followUsers = account.followingKeySet()
val notes = LocalCache.notes.values
.asSequence()
.filter {
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
val longFormNotes = LocalCache.addressables.values
.asSequence()
.filter {
(it.event is LongTextNoteEvent) && it.replyTo.isNullOrEmpty()
}
.filter {
// does not show events already in the public chat list
(it.channel() == null || it.channel() !in followChannels) &&
// does not show people the user already follows
(it.author?.pubkeyHex !in followUsers)
}
.filter { account.isAcceptable(it) }
.toList()
return (notes + longFormNotes)
.sortedBy { it.createdAt() }
.reversed()
}
}
@@ -0,0 +1,39 @@
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.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HashtagFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
var tag: String? = null
override fun feed(): List<Note> {
val myTag = tag ?: return emptyList()
return LocalCache.notes.values
.asSequence()
.filter {
(
it.event is TextNoteEvent ||
it.event is LongTextNoteEvent ||
it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent
) &&
it.event?.isTaggedHash(myTag) == true
}
.filter { account.isAcceptable(it) }
.sortedBy { it.createdAt() }
.toList()
.reversed()
}
fun loadHashtag(account: Account, tag: String?) {
this.account = account
this.tag = tag
}
}
@@ -11,13 +11,15 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
return LocalCache.notes.values
.filter {
(it.event is TextNoteEvent || it.event is RepostEvent) &&
it.author in user.follows &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
it.author?.let { !account.isHidden(it) } ?: true &&
!it.isNewThread()
}
.sortedBy { it.createdAt() }
@@ -12,11 +12,13 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
override fun feed(): List<Note> {
val user = account.userProfile()
val followingKeySet = user.cachedFollowingKeySet()
val followingTagSet = user.cachedFollowingTagSet()
val notes = LocalCache.notes.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -25,7 +27,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
val longFormNotes = LocalCache.addressables.values
.filter { it ->
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
it.author in user.follows &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it) } ?: true &&
it.isNewThread()
@@ -1,6 +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.service.model.*
@@ -8,34 +9,32 @@ object NotificationFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
return account.userProfile()
.taggedPosts
val loggedInUser = account.userProfile()
return LocalCache.notes.values
.asSequence()
.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 &&
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent
it.event !is BadgeProfilesEvent &&
it.event?.isTaggedUser(loggedInUser.pubkeyHex) ?: false &&
(it.author == null || (!account.isHidden(it.author!!) && it.author != loggedInUser))
}
.filter { it ->
it.event !is TextNoteEvent ||
it.replyTo?.any { it.author == account.userProfile() } == true ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.any { it.author == loggedInUser } == true ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is ReactionEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.filter {
it.event !is RepostEvent ||
it.replyTo?.lastOrNull()?.author == account.userProfile() ||
account.userProfile() in it.directlyCiteUsers()
it.replyTo?.lastOrNull()?.author == loggedInUser ||
loggedInUser in it.directlyCiteUsers()
}
.sortedBy { it.createdAt() }
.toList()
@@ -14,7 +14,8 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.followers
?.filter { account.isAcceptable(it) } ?: emptyList()
return user?.let { myUser ->
LocalCache.users.values.filter { it.isFollowing(myUser) && account.isAcceptable(it) }
} ?: emptyList()
}
}
@@ -14,7 +14,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
}
override fun feed(): List<User> {
return user?.follows
return user?.latestContactList?.unverifiedFollowKeySet()?.mapNotNull {
LocalCache.checkGetOrCreateUser(it)
}?.toSet()
?.filter { account.isAcceptable(it) }
?.reversed() ?: emptyList()
}
@@ -20,6 +20,7 @@ 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.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
@@ -94,6 +95,16 @@ fun AppNavigation(
})
}
Route.Hashtag.let { route ->
composable(route.route, route.arguments, content = {
HashtagScreen(
tag = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})
}
Route.Room.let { route ->
composable(route.route, route.arguments, content = {
ChatroomScreen(
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
@@ -138,6 +139,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
NostrSingleChannelDataSource.printCounter()
NostrSingleUserDataSource.printCounter()
NostrThreadDataSource.printCounter()
NostrHashtagDataSource.printCounter()
NostrUserProfileDataSource.printCounter()
@@ -197,11 +197,11 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
})
) {
Row() {
Text("${accountUserFollows.follows.size}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers.size}", fontWeight = FontWeight.Bold)
Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
Text(stringResource(R.string.followers))
}
}
@@ -65,6 +65,12 @@ sealed class Route(
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Hashtag : Route(
route = "Hashtag/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,
@@ -41,9 +41,11 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
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.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -127,7 +129,10 @@ fun ChatroomCompose(
)
}
} else {
val replyAuthorBase = note.mentions?.first()
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
@@ -277,7 +277,7 @@ fun ChatroomMessageCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == accountUser ||
(note.author?.let { accountUser.isFollowing(it) } ?: true) ||
(note.author?.let { accountUser.isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -344,7 +344,7 @@ fun ChatroomMessageCompose(
}
}
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -26,11 +26,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -114,18 +118,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.zapEvents.forEach {
NoteAuthorPicture(
note = it.key,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.zapEvents.keys, navController, account)
}
}
@@ -146,18 +139,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.boostEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.boostEvents, navController, account)
}
}
@@ -178,18 +160,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
multiSetCard.likeEvents.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
}
AuthorGallery(multiSetCard.likeEvents, navController, account)
}
}
@@ -218,3 +189,54 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
}
}
}
@Composable
fun AuthorGallery(
authorNotes: Collection<Note>,
navController: NavController,
account: Account
) {
val accountState by account.userProfile().live().follows.observeAsState()
val accountUser = accountState?.user ?: return
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
FastNoteAuthorPicture(
note = it,
navController = navController,
userAccount = accountUser,
size = 35.dp
)
}
}
}
}
@Composable
fun FastNoteAuthorPicture(
note: Note,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
// can't be null if here
val author = note.author ?: return
val userState by author.live().metadata.observeAsState()
val user = userState?.user ?: return
val showFollowingMark = userAccount.isFollowingCached(user) || user == userAccount
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = pictureModifier,
onClick = {
navController.navigate("User/${user.pubkeyHex}")
}
)
}
@@ -1,11 +1,13 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Intent
import android.graphics.Bitmap
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
@@ -19,6 +21,7 @@ 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.luminance
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -32,6 +35,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.get
import androidx.navigation.NavController
import coil.compose.AsyncImage
import com.google.accompanist.flowlayout.FlowRow
@@ -59,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -267,6 +273,15 @@ fun NoteCompose(
)
}
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
if (firstTag != null) {
ClickableText(
text = AnnotatedString(" #$firstTag"),
onClick = { navController.navigate("Hashtag/$firstTag") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
)
}
Text(
timeAgo(note.createdAt(), context = context),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -294,7 +309,12 @@ fun NoteCompose(
Spacer(modifier = Modifier.height(3.dp))
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) {
NoteCompose(
@@ -316,10 +336,13 @@ fun NoteCompose(
navController = navController
)
} else {
ReplyInformation(note.replyTo, note.mentions, account, navController)
ReplyInformation(note.replyTo, sortedMentions, account, navController)
}
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
val sortedMentions = note.mentions?.toSet()?.sortedBy { account.userProfile().isFollowing(it) }
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
note.channel()?.let {
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
@@ -381,14 +404,16 @@ fun NoteCompose(
Text(text = stringResource(R.string.award_granted_to))
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
note.mentions?.forEach {
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
noteEvent.awardees()
.map { LocalCache.getOrCreateUser(it) }
.forEach {
UserPicture(
user = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
note.replyTo?.firstOrNull()?.let {
@@ -442,7 +467,7 @@ fun NoteCompose(
val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -476,17 +501,20 @@ fun NoteCompose(
@Composable
fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
var backgroundFromImage by remember { mutableStateOf(background) }
Row(
modifier = Modifier
.padding(10.dp)
.clip(shape = CutCornerShape(20, 20, 0, 0))
.clip(shape = CutCornerShape(20, 20, 20, 20))
.border(
5.dp,
MaterialTheme.colors.primary.copy(alpha = 0.32f),
CutCornerShape(20)
)
.background(backgroundFromImage)
) {
Column {
badgeData.image()?.let {
@@ -497,7 +525,11 @@ fun BadgeDisplay(baseNote: Note) {
it
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
onSuccess = {
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
backgroundFromImage = Color(backgroundColor)
}
)
}
@@ -508,7 +540,8 @@ fun BadgeDisplay(baseNote: Note) {
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
.padding(start = 10.dp, end = 10.dp),
color = if (backgroundFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
)
}
@@ -672,7 +705,8 @@ fun NoteAuthorPicture(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
)
@@ -695,7 +729,6 @@ fun UserPicture(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
baseUser: User,
@@ -708,24 +741,52 @@ fun UserPicture(
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
val showFollowingMark = accountUser.isFollowingCached(user) || user == accountUser
UserPicture(
userHex = user.pubkeyHex,
userPicture = user.profilePicture(),
showFollowingMark = showFollowingMark,
size = size,
modifier = modifier,
onClick = onClick?.let { { it(user) } },
onLongClick = onLongClick?.let { { it(user) } }
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserPicture(
userHex: String,
userPicture: String?,
showFollowingMark: Boolean,
size: Dp,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null
) {
Box(
Modifier
.width(size)
.height(size)
) {
RobohashAsyncImageProxy(
robot = user.pubkeyHex,
model = ResizeImage(user.profilePicture(), size),
robot = userHex,
model = ResizeImage(userPicture, size),
contentDescription = stringResource(id = R.string.profile_image),
modifier = modifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null && onLongClick != null) {
this.combinedClickable(onClick = { onClick(user) }, onLongClick = { onLongClick(user) })
this.combinedClickable(onClick = onClick, onLongClick = onLongClick)
} else if (onClick != null) {
this.clickable(onClick = { onClick(user) })
this.clickable(onClick = onClick)
} else {
this
}
@@ -733,10 +794,7 @@ fun UserPicture(
)
val accountState by baseUserAccount.live().follows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
if (showFollowingMark) {
Box(
Modifier
.width(size.div(3.5f))
@@ -769,12 +827,13 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val clipboardManager = LocalClipboardManager.current
val appContext = LocalContext.current.applicationContext
val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) }
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile() && !accountViewModel.accountLiveData.value?.account?.userProfile()!!.isFollowing(note.author!!)) {
if (!accountViewModel.isFollowing(note.author)) {
DropdownMenuItem(onClick = {
accountViewModel.follow(
note.author ?: return@DropdownMenuItem
@@ -814,57 +873,22 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
Text(stringResource(R.string.broadcast))
}
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
Divider()
if (accountViewModel.isLoggedUser(note.author)) {
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
Text(stringResource(R.string.request_deletion))
}
}
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
Divider()
DropdownMenuItem(onClick = {
note.author?.let {
accountViewModel.hide(it)
}; onDismiss()
}) {
Text(stringResource(R.string.block_hide_user))
}
Divider()
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.SPAM)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_spam_scam))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.PROFANITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_hateful_speech))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_impersonation))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.NUDITY)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_nudity_porn))
}
DropdownMenuItem(onClick = {
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}) {
Text(stringResource(R.string.report_illegal_behaviour))
} else {
DropdownMenuItem(onClick = { reportDialogShowing = true }) {
Text("Block / Report")
}
}
}
if (reportDialogShowing) {
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
reportDialogShowing = false
onDismiss()
}
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -16,6 +17,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.Divider
import androidx.compose.material.Icon
@@ -24,11 +27,13 @@ import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,6 +45,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
@@ -57,9 +63,11 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.coroutines.launch
fun lightenColor(color: Color, amount: Float): Color {
private fun lightenColor(color: Color, amount: Float): Color {
var argb = color.toArgb()
val hslOut = floatArrayOf(0f, 0f, 0f)
ColorUtils.colorToHSL(argb, hslOut)
@@ -71,7 +79,7 @@ fun lightenColor(color: Color, amount: Float): Color {
val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" }
@Composable
fun VerticalDivider(color: Color) =
private fun VerticalDivider(color: Color) =
Divider(
color = color,
modifier = Modifier
@@ -88,9 +96,17 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
val scope = rememberCoroutineScope()
var showSelectTextDialog by remember { mutableStateOf(false) }
var showDeleteAlertDialog by remember { mutableStateOf(false) }
val isOwnNote = note.author == accountViewModel.userProfile()
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author!!)
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
@@ -106,7 +122,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Card(
modifier = Modifier.shadow(elevation = 6.dp, shape = cardShape),
shape = cardShape,
backgroundColor = MaterialTheme.colors.primary
backgroundColor = backgroundColor
) {
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
@@ -134,6 +150,19 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
showToast(R.string.copied_note_id_to_clipboard)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) {
if (accountViewModel.hideBlockAlertDialog) {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
} else {
showBlockAlertDialog = true
}
}
}
}
Divider(
color = primaryLight,
@@ -144,7 +173,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
if (isOwnNote) {
NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) {
if (accountViewModel.hideDeleteRequestInfo()) {
if (accountViewModel.hideDeleteRequestDialog) {
accountViewModel.delete(note)
onDismiss()
} else {
@@ -187,6 +216,14 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
if (!isOwnNote) {
VerticalDivider(primaryLight)
NoteQuickActionItem(Icons.Default.Report, stringResource(R.string.quick_action_report)) {
showReportDialog = true
}
}
}
}
}
@@ -200,36 +237,24 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
}
if (showDeleteAlertDialog) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_title))
},
text = {
Text(text = stringResource(R.string.quick_action_request_deletion_alert_body))
},
buttons = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(
onClick = {
accountViewModel.setHideDeleteRequestInfo()
accountViewModel.delete(note)
onDismiss()
}
) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(
onClick = { accountViewModel.delete(note); onDismiss() }
) {
Text(stringResource(R.string.quick_action_delete_button))
}
}
}
)
DeleteAlertDialog(note, accountViewModel) {
showDeleteAlertDialog = false
onDismiss()
}
}
if (showBlockAlertDialog) {
BlockAlertDialog(note, accountViewModel) {
showBlockAlertDialog = false
onDismiss()
}
}
if (showReportDialog) {
ReportNoteDialog(note, accountViewModel) {
showReportDialog = false
onDismiss()
}
}
}
@@ -245,9 +270,99 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
modifier = Modifier
.size(24.dp)
.padding(bottom = 5.dp),
tint = Color.White
)
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
}
}
@Composable
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.quick_action_request_deletion_alert_title),
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
buttonIcon = Icons.Default.Delete,
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
accountViewModel.delete(note)
onDismiss()
},
onClickDontShowAgain = {
accountViewModel.delete(note)
accountViewModel.dontShowDeleteRequestDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun BlockAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) =
QuickActionAlertDialog(
title = stringResource(R.string.report_dialog_block_hide_user_btn),
textContent = stringResource(R.string.report_dialog_blocking_a_user),
buttonIcon = Icons.Default.Block,
buttonText = stringResource(R.string.quick_action_block_dialog_btn),
buttonColors = ButtonDefaults.buttonColors(
backgroundColor = WarningColor,
contentColor = Color.White
),
onClickDoOnce = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
},
onClickDontShowAgain = {
note.author?.let { accountViewModel.hide(it) }
accountViewModel.dontShowBlockAlertDialog()
onDismiss()
},
onDismiss = onDismiss
)
@Composable
private fun QuickActionAlertDialog(
title: String,
textContent: String,
buttonIcon: ImageVector,
buttonText: String,
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
onClickDoOnce: () -> Unit,
onClickDontShowAgain: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(textContent)
},
buttons = {
Row(
modifier = Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = onClickDontShowAgain) {
Text(stringResource(R.string.quick_action_dont_show_again_button))
}
Button(onClick = onClickDoOnce, colors = buttonColors) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = buttonIcon,
contentDescription = null
)
Spacer(Modifier.width(8.dp))
Text(buttonText)
}
}
}
}
)
}
@@ -30,7 +30,7 @@ fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Accou
@Composable
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowing(it) }
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowingCached(it) }
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
FlowRow() {
@@ -72,7 +72,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (userFollows.isFollowing(baseUser)) {
} else if (userFollows.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
@@ -117,7 +117,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
}
} else if (userFollows.isFollowing(baseAuthor)) {
} else if (userFollows.isFollowingCached(baseAuthor)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }
@@ -26,6 +26,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -100,7 +102,11 @@ private fun FeedLoaded(
val route = if (channel != null) {
"Channel/${channel.idHex}"
} else {
val replyAuthorBase = note.mentions?.first()
val replyAuthorBase =
(note.event as? PrivateDmEvent)
?.recipientPubKey()
?.let { LocalCache.getOrCreateUser(it) }
var userToComposeOn = note.author!!
if (replyAuthorBase != null) {
if (note.author == account.userProfile()) {
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
@@ -33,6 +34,7 @@ class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
@@ -53,7 +53,7 @@ class RelayFeedViewModel : ViewModel() {
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
val newRelaysFromRecord = currentUser?.latestContactList?.relays()?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
RelayInfo(it.key, 0, 0)
} else {
@@ -328,7 +328,7 @@ fun NoteMaster(
val eventContent = note.event?.content()
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowing(it) } ?: true) ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) {
@@ -73,8 +73,8 @@ class AccountViewModel(private val account: Account) : ViewModel() {
)
}
fun report(note: Note, type: ReportEvent.ReportType) {
account.report(note, type)
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
account.report(note, type, content)
}
fun report(user: User, type: ReportEvent.ReportType) {
@@ -125,15 +125,26 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.unfollow(user)
}
fun isFollowing(user: User): Boolean {
return account.userProfile().isFollowing(user)
fun isLoggedUser(user: User?): Boolean {
return account.userProfile() == user
}
fun hideDeleteRequestInfo(): Boolean {
return account.hideDeleteRequestInfo
fun isFollowing(user: User?): Boolean {
if (user == null) return false
return account.userProfile().isFollowingCached(user)
}
fun setHideDeleteRequestInfo() {
account.setHideDeleteRequestInfo()
val hideDeleteRequestDialog: Boolean
get() = account.hideDeleteRequestDialog
fun dontShowDeleteRequestDialog() {
account.setHideDeleteRequestDialog()
}
val hideBlockAlertDialog: Boolean
get() = account.hideBlockAlertDialog
fun dontShowBlockAlertDialog() {
account.setHideBlockAlertDialog()
}
}
@@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -35,7 +33,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -56,10 +53,9 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
@@ -108,7 +104,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
Column(Modifier.fillMaxHeight()) {
NostrChatroomDataSource.withUser?.let {
ChatroomHeader(it, navController = navController)
ChatroomHeader(it, account.userProfile(), navController = navController)
}
Column(
@@ -210,7 +206,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
}
@Composable
fun ChatroomHeader(baseUser: User, navController: NavController) {
fun ChatroomHeader(baseUser: User, accountUser: User, navController: NavController) {
Column(
modifier = Modifier.clickable(
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
@@ -218,17 +214,10 @@ fun ChatroomHeader(baseUser: User, navController: NavController) {
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.live().metadata.observeAsState()
val author = authorState?.user!!
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = ResizeImage(author.profilePicture(), 35.dp),
contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
UserPicture(
baseUser = baseUser,
baseUserAccount = accountUser,
size = 35.dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
@@ -0,0 +1,125 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val lifeCycleOwner = LocalLifecycleOwner.current
if (tag != null) {
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
LaunchedEffect(tag) {
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
HashtagFeedFilter.loadHashtag(account, tag)
NostrHashtagDataSource.loadHashtag(tag)
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Hashtag Stop")
HashtagFeedFilter.loadHashtag(account, null)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, navController, null)
}
}
}
}
@Composable
fun HashtagHeader(tag: String, account: Account) {
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val coroutineScope = rememberCoroutineScope()
Column() {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"#$tag",
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
if (userFollows.isFollowingHashtagCached(tag)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
}
}
}
}
}
Divider(
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
thickness = 0.25.dp
)
}
}
@@ -198,13 +198,13 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
val userFollows = userState?.user?.transientFollowCount() ?: "--"
Text(text = "$userFollows ${stringResource(R.string.follows)}")
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollowers = userState?.user?.followers?.size ?: "--"
val userFollowers = userState?.user?.transientFollowerCount() ?: "--"
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
},
@@ -236,7 +236,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
@@ -373,7 +373,7 @@ private fun ProfileHeader(
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (accountUser.isFollowing(baseUser)) {
} else if (accountUser.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
@@ -457,7 +457,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 50.dp)
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
}
}
}
@@ -585,7 +585,8 @@ fun BadgeThumb(
robot = "authornotfound",
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.fillMaxSize(1f)
.width(size)
.height(size)
.background(MaterialTheme.colors.background)
)
} else {
@@ -594,7 +595,8 @@ fun BadgeThumb(
model = image,
contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier
.fillMaxSize(1f)
.width(size)
.height(size)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
@@ -0,0 +1,172 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.layout.Arrangement
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Report
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
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 com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.theme.WarningColor
@Composable
fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
val reportTypes = listOf(
Pair(ReportEvent.ReportType.SPAM, stringResource(R.string.report_dialog_spam)),
Pair(ReportEvent.ReportType.PROFANITY, stringResource(R.string.report_dialog_profanity)),
Pair(ReportEvent.ReportType.IMPERSONATION, stringResource(R.string.report_dialog_impersonation)),
Pair(ReportEvent.ReportType.NUDITY, stringResource(R.string.report_dialog_nudity)),
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
)
val reasonOptions = reportTypes.map { it.second }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableStateOf(-1) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = "Block and Report") },
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colors.onSurface
)
}
},
backgroundColor = MaterialTheme.colors.surface,
elevation = 0.dp
)
}
) { pad ->
Column(
modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()),
verticalArrangement = Arrangement.SpaceAround
) {
SpacerH16()
SectionHeader(text = "Block")
SpacerH16()
Text(
text = stringResource(R.string.report_dialog_blocking_a_user)
)
SpacerH16()
ActionButton(
text = stringResource(R.string.report_dialog_block_hide_user_btn),
icon = Icons.Default.Block,
onClick = {
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
)
SpacerH16()
Divider(color = MaterialTheme.colors.onSurface, thickness = 0.25.dp)
SpacerH16()
SectionHeader(text = stringResource(R.string.report_dialog_report_btn))
SpacerH16()
Text(stringResource(R.string.report_dialog_reminder_public))
SpacerH16()
TextSpinner(
label = stringResource(R.string.report_dialog_select_reason_label),
placeholder = stringResource(R.string.report_dialog_select_reason_placeholder),
options = reasonOptions,
onSelect = {
selectedReason = it
},
modifier = Modifier.fillMaxWidth()
)
SpacerH16()
OutlinedTextField(
value = additionalReason,
onValueChange = { additionalReason = it },
placeholder = { Text(text = stringResource(R.string.report_dialog_additional_reason_placeholder)) },
label = { Text(stringResource(R.string.report_dialog_additional_reason_label)) },
modifier = Modifier.fillMaxWidth()
)
SpacerH16()
ActionButton(
text = stringResource(R.string.report_dialog_post_report_btn),
icon = Icons.Default.Report,
enabled = selectedReason in 0..reportTypes.lastIndex,
onClick = {
accountViewModel.report(note, reportTypes[selectedReason].first, additionalReason)
note.author?.let { accountViewModel.hide(it) }
onDismiss()
}
)
}
}
}
}
@Composable
private fun SpacerH16() = Spacer(modifier = Modifier.height(16.dp))
@Composable
private fun SectionHeader(text: String) = Text(
text = text,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface,
fontSize = 18.sp
)
@Composable
private fun ActionButton(text: String, icon: ImageVector, enabled: Boolean = true, onClick: () -> Unit) = Button(
onClick = onClick,
enabled = enabled,
colors = ButtonDefaults.buttonColors(backgroundColor = WarningColor),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = text, color = Color.White)
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -72,6 +73,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
import kotlinx.coroutines.channels.Channel as CoroutineChannel
@Composable
@@ -95,10 +97,13 @@ fun SearchScreen(
if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start")
NostrGlobalDataSource.start()
NostrSearchEventOrUserDataSource.start()
feedViewModel.refresh()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Global Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
NostrGlobalDataSource.stop()
}
}
@@ -126,7 +131,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val hashtagResults = remember { mutableStateOf<List<String>>(emptyList()) }
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val onlineSearch = NostrSearchEventOrUserDataSource
@@ -149,6 +156,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
hashtagResults.value = findHashtags(it)
if (it.removePrefix("npub").removePrefix("note").length >= 4) {
onlineSearch.search(it.trim())
}
@@ -156,6 +165,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
}
}
}
@@ -236,8 +248,15 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
)
),
state = listState
) {
itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
navController.navigate("Hashtag/$item")
}
}
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
@@ -266,6 +285,57 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
}
}
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
fun findHashtags(content: String): List<String> {
val matcher = hashtagSearch.matcher(content)
val returningList = mutableSetOf<String>()
while (matcher.find()) {
try {
val tag = matcher.group(1)
if (tag != null && tag.isNotBlank()) {
returningList.add(tag)
}
} catch (e: Exception) {
}
}
return returningList.toList()
}
@Composable
fun HashtagLine(tag: String, onClick: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Search hashtag: #$tag",
fontWeight = FontWeight.Bold
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@Composable
fun UserLine(
baseUser: User,
@@ -12,3 +12,5 @@ val Following = Color(0xFF03DAC5)
val Nip05 = Color(0xFF01BAFF)
val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue
val WarningColor = Color(0xFFC62828)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+6 -7
View File
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">شكرا جزيلا!</string>
<string name="amount_in_sats">المبلغ في Sats</string>
<string name="send_sats">إرسال Sats</string>
<string name="never_translate_from">"لا تترجم ابداً من "</string>
<string name="error_parsing_preview_for">خطأ في تحليل المعاينة لـ %1$s : %2$s</string>
<string name="preview_card_image_for">معاينة صورة البطاقة لـ %1$s</string>
<string name="new_channel">قناة جديدة</string>
@@ -153,12 +152,12 @@
<string name="public_chat">الدردشة العامة</string>
<string name="posts_received">تم استلام المشاركات</string>
<string name="remove">ازالة</string>
<string name="auto">تلقائي</string>
<string name="translated_from">مترجم من</string>
<string name="to">الى</string>
<string name="show_in">تظهر في</string>
<string name="first">اولا</string>
<string name="always_translate_to">ترجمة إلى</string>
<string name="translations_auto">تلقائي</string>
<string name="translations_translated_from">مترجم من</string>
<string name="translations_to">الى</string>
<string name="translations_show_in_lang_first">تظهر في %1$s اولا</string>
<string name="translations_always_translate_to_lang">ترجمة إلى %1$s</string>
<string name="translations_never_translate_from_lang">لا تترجم ابداً من %1$s</string>
<string name="never">ابداً</string>
<string name="now">الان</string>
<string name="h">س</string>
+6 -7
View File
@@ -71,7 +71,6 @@
<string name="thank_you_so_much">¡Muchas gracias!</string>
<string name="amount_in_sats">Cantidad en Sats</string>
<string name="send_sats">Enviar Sats</string>
<string name="never_translate_from">"Nunca traducir desde "</string>
<string name="error_parsing_preview_for">"Error al analizar la vista previa de %1$s : %2$s"</string>
<string name="preview_card_image_for">"Imagen de vista previa para %1$s"</string>
<string name="new_channel">Nuevo canal</string>
@@ -169,12 +168,12 @@
<string name="public_chat">Chat público</string>
<string name="posts_received">publicaciones recibidas</string>
<string name="remove">Eliminar</string>
<string name="auto">Auto</string>
<string name="translated_from">traducido de</string>
<string name="to">a</string>
<string name="show_in">Mostrar en</string>
<string name="first">primero</string>
<string name="always_translate_to">"Traducir siempre a "</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">traducido de</string>
<string name="translations_to">a</string>
<string name="translations_show_in_lang_first">Mostrar en %1$s primero</string>
<string name="translations_always_translate_to_lang">Traducir siempre a %1$s</string>
<string name="translations_never_translate_from_lang">Nunca traducir desde %1$s</string>
<string name="never">nunca</string>
<string name="now">ahora</string>
<string name="h">h</string>
+6 -7
View File
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Merci beaucoup!</string>
<string name="amount_in_sats">Montant en Sats</string>
<string name="send_sats">Envoyer des Sats</string>
<string name="never_translate_from">"Ne jamais traduire depuis "</string>
<string name="error_parsing_preview_for">"Erreur d\'analyse de l\'aperçu pour %1$s : %2$s"</string>
<string name="preview_card_image_for">"Aperçu de l\'image pour %1$s"</string>
<string name="new_channel">Nouveau canal</string>
@@ -154,12 +153,12 @@
<string name="public_chat">Chat public</string>
<string name="posts_received">messages reçus</string>
<string name="remove">Retirer</string>
<string name="auto">Auto</string>
<string name="translated_from">traduit de</string>
<string name="to">vers</string>
<string name="show_in">Montre en</string>
<string name="first">en premier</string>
<string name="always_translate_to">"Toujours traduire en "</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">traduit de</string>
<string name="translations_to">vers</string>
<string name="translations_show_in_lang_first">Montre en %1$s en premier</string>
<string name="translations_always_translate_to_lang">Toujours traduire en %1$s</string>
<string name="translations_never_translate_from_lang">Ne jamais traduire depuis %1$s</string>
<string name="never">jamais</string>
<string name="now">maintenant</string>
<string name="h">h</string>
+14 -15
View File
@@ -22,7 +22,7 @@
<string name="copy_user_pubkey">A felhasználó PubKulcs másolása</string>
<string name="copy_note_id">A bejegyzés azonosítójának másolása</string>
<string name="broadcast">Közvetít</string>
<string name="block_hide_user"><![CDATA[Block & Hide User]]></string>
<string name="block_hide_user"><![CDATA[Tiltás és Felhasználó elrejtése]]></string>
<string name="report_spam_scam">Spam / Csalás bejelentés</string>
<string name="report_impersonation">Megszemélyesítés bejelentés</string>
<string name="report_explicit_content">Szókimondó tartalom bejelentés</string>
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Nagyon szépen köszönöm!</string>
<string name="amount_in_sats">Összeg Sats-ban</string>
<string name="send_sats">Sats küldése</string>
<string name="never_translate_from">"Soha ne fordítsd erről "</string>
<string name="error_parsing_preview_for">"Hiba a következő előnézetében %1$s : %2$s"</string>
<string name="preview_card_image_for">"Kártyakép előnézete %1$s"</string>
<string name="new_channel">Új Csatorna</string>
@@ -66,7 +65,7 @@
<string name="description">Leírás</string>
<string name="about_us">"Rólunk.. "</string>
<string name="what_s_on_your_mind">Mire gondolsz?</string>
<string name="post">Hozzászólás</string>
<string name="post">Küldés</string>
<string name="save">Mentés</string>
<string name="create">Létrehoz</string>
<string name="cancel">Törlés</string>
@@ -86,7 +85,7 @@
<string name="my_username">Felhasználónevem</string>
<string name="about_me">Rólam</string>
<string name="avatar_url">Avatarom URL-je</string>
<string name="banner_url">Bannerem UR-jeL</string>
<string name="banner_url">Bannerem URL-je</string>
<string name="website_url">Oldalam URL-je</string>
<string name="ln_address">LN Cím</string>
<string name="ln_url_outdated">LN URL (elavult)</string>
@@ -95,7 +94,7 @@
<string name="upload_image">Kép feltöltése</string>
<string name="uploading">Feltöltés…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
<string name="reply_here">"válaszolj ide.. "</string>
<string name="reply_here">"itt tudsz válaszolni.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Megosztáshoz a bejegyzés azonosítót a vágólapra másolja</string>
<string name="copy_channel_id_note_to_the_clipboard">Bejegyzés azonosító vágólapra másolása</string>
<string name="edits_the_channel_metadata">Szerkeszti a csatorna metaadatait</string>
@@ -103,7 +102,7 @@
<string name="known">Ismert</string>
<string name="new_requests">Új kérések</string>
<string name="blocked_users">Letiltott felhasználók</string>
<string name="new_threads">Új szálak</string>
<string name="new_threads">Bejegyzések</string>
<string name="conversations">Beszélgetések</string>
<string name="notes">Bejegyzések</string>
<string name="replies">Válaszok</string>
@@ -131,7 +130,7 @@
<string name="hide_password">Jelszó elrejtése</string>
<string name="invalid_key">Érvénytelen kulcs</string>
<string name="i_accept_the">"Elfogadom a "</string>
<string name="terms_of_use">használati feltételek</string>
<string name="terms_of_use">használati feltételeket</string>
<string name="acceptance_of_terms_is_required">A feltételek elfogadása szükséges</string>
<string name="key_is_required">Kulcs szükséges</string>
<string name="login">Bejelentkezés</string>
@@ -148,18 +147,18 @@
<string name="description_to">leírását erre</string>
<string name="and_picture_to">és a képet erre</string>
<string name="leave">Kilépés</string>
<string name="unfollow">Leíratkozás</string>
<string name="unfollow">Kikövetem</string>
<string name="channel_created">Csatorna létrehozva</string>
<string name="channel_information_changed_to">"A csatornainformáció a következőre módosult"</string>
<string name="public_chat">Publikus Chat</string>
<string name="posts_received">beérkezett hozzászólások</string>
<string name="remove">Eltávolítás</string>
<string name="auto">Automatikus</string>
<string name="translated_from">fordítás erről</string>
<string name="to">erre</string>
<string name="show_in">Mutasd ebben</string>
<string name="first">első</string>
<string name="always_translate_to">Mindig fordítsa le</string>
<string name="translations_auto">Automatikus</string>
<string name="translations_translated_from">fordítás erről</string>
<string name="translations_to">erre</string>
<string name="translations_show_in_lang_first">Először %1$s nyelven</string>
<string name="translations_always_translate_to_lang">Mindig fordítsa le %1$s-ra</string>
<string name="translations_never_translate_from_lang">Soha ne fordíts %1$s-ról</string>
<string name="never">soha</string>
<string name="now">most</string>
<string name="h">ó</string>
@@ -175,7 +174,7 @@
<string name="mark_all_as_read">Összes megjelölése olvasottként</string>
<string name="account_backup_tips_md">
## Kulcs- és biztonsági mentési tippek
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-al kezdődik. Bárki, aki hozzáfér az Ön titkos kulcsához, közzétehet tartalmat az Ön személyazonosságának használatával.
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet.
\n\n- **Ne** helyezze el titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik.
\n- Az Amethyst fejlesztők a titkos kulcsodat **soha** nem fogják elkérni.
\n- A fiók-helyreállításhoz, titkos kulcsáról **mindig** készítsen biztonságos biztonsági másolatot. Javasoljuk a jelszókezelő használatát.
+6 -7
View File
@@ -57,7 +57,6 @@
<string name="thank_you_so_much">Hartelijk bedankt!</string>
<string name="amount_in_sats">Bedrag in sats</string>
<string name="send_sats">Verstuur sats</string>
<string name="never_translate_from">"Nooit vertalen vanuit "</string>
<string name="error_parsing_preview_for">"Error parsing preview voor %1$s : %2$s"</string>
<string name="preview_card_image_for">"Voorbeeld kaartafbeelding voor %1$s"</string>
<string name="new_channel">Nieuw kanaal</string>
@@ -156,12 +155,12 @@
<string name="posts_received">berichten ontvangen</string>
<string name="remove">verwijderen</string>
<string name="sats" translatable="false">sats</string>
<string name="auto">Auto</string>
<string name="translated_from">vertaald van</string>
<string name="to">naar</string>
<string name="show_in">Laten zien in</string>
<string name="first">eerste</string>
<string name="always_translate_to">Altijd vertalen naar</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">vertaald van</string>
<string name="translations_to">naar</string>
<string name="translations_show_in_lang_first">Eerst laten zien in %1$s</string>
<string name="translations_always_translate_to_lang">Altijd vertalen naar %1$s</string>
<string name="translations_never_translate_from_lang">Nooit vertalen vanuit %1$s</string>
<string name="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">nooit</string>
+6 -7
View File
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Obrigado!</string>
<string name="amount_in_sats">Quantidade em Sats</string>
<string name="send_sats">Enviar Sats</string>
<string name="never_translate_from">"Nunca traduzir de "</string>
<string name="error_parsing_preview_for">Erro ao analisar a visualização para %1$s: %2$s</string>
<string name="preview_card_image_for">Visualize a imagem do cartão para %1$s</string>
<string name="new_channel">Novo Canal</string>
@@ -154,12 +153,12 @@
<string name="public_chat">Chat público</string>
<string name="posts_received">postagens recebidas</string>
<string name="remove">Remover</string>
<string name="auto">"Automaticamente "</string>
<string name="translated_from">"traduzido de "</string>
<string name="to">para</string>
<string name="show_in">Mostrar em</string>
<string name="first">primeiro</string>
<string name="always_translate_to">"Sempre traduzir para "</string>
<string name="translations_auto">"Automaticamente "</string>
<string name="translations_translated_from">"traduzido de "</string>
<string name="translations_to">para</string>
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
<string name="translations_always_translate_to_lang">Sempre traduzir para %1$s</string>
<string name="translations_never_translate_from_lang">Nunca traduzir de %1$s</string>
<string name="never">nunca</string>
<string name="now">agora</string>
<string name="h">h</string>
+6 -7
View File
@@ -55,7 +55,6 @@
<string name="thank_you_so_much">Большое спасибо!</string>
<string name="amount_in_sats">Сумма в sat</string>
<string name="send_sats">Отправить</string>
<string name="never_translate_from">"Не переводить с "</string>
<string name="error_parsing_preview_for">"Не удалось создать предпросмотр для %1$s : %2$s"</string>
<string name="preview_card_image_for">"Предпросмотр для %1$s"</string>
<string name="new_channel">Новый канал</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Публичный чат</string>
<string name="posts_received">записей получено</string>
<string name="remove">Удалить</string>
<string name="auto">Автоматически</string>
<string name="translated_from">переведено с</string>
<string name="to">на</string>
<string name="show_in">Показать сперва на</string>
<string name="first" />
<string name="always_translate_to">Всегда переводить на</string>
<string name="translations_auto">Автоматически</string>
<string name="translations_translated_from">переведено с</string>
<string name="translations_to">на</string>
<string name="translations_show_in_lang_first">Показать сперва на %1$s</string>
<string name="translations_always_translate_to_lang">Всегда переводить на %1$s</string>
<string name="translations_never_translate_from_lang">Не переводить с %1$s</string>
<string name="never">никогда</string>
<string name="now">сейчас</string>
<string name="h">ч</string>
+6 -7
View File
@@ -56,7 +56,6 @@
<string name="thank_you_so_much">Çok teşekkürler!</string>
<string name="amount_in_sats">Satlarda Miktar</string>
<string name="send_sats">Satlar Gönder</string>
<string name="never_translate_from">Asla çevirme</string>
<string name="new_channel">Yeni Kanal</string>
<string name="channel_name">Kanal Adı</string>
<string name="my_awesome_group">Benim Harika Grubum</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Herkese Açık Chat</string>
<string name="posts_received">alınan gönderiler</string>
<string name="remove">Kaldır</string>
<string name="auto">Otomatik</string>
<string name="translated_from">şundan çevirildi</string>
<string name="to">şuna</string>
<string name="show_in">Şunda göster</string>
<string name="first">ilk</string>
<string name="always_translate_to">Her zaman şuna çevir</string>
<string name="translations_auto">Otomatik</string>
<string name="translations_translated_from">şundan çevirildi</string>
<string name="translations_to">şuna</string>
<string name="translations_show_in_lang_first">Şunda göster %1$s ilk</string>
<string name="translations_always_translate_to_lang">Her zaman şuna çevir %1$s</string>
<string name="translations_never_translate_from_lang">Asla çevirme %1$s</string>
<string name="never">asla</string>
<string name="now">şimdi</string>
<string name="h">h</string>
+6 -7
View File
@@ -55,7 +55,6 @@
<string name="thank_you_so_much">Дуже дякуємо!</string>
<string name="amount_in_sats">Сума в sat</string>
<string name="send_sats">Надіслати</string>
<string name="never_translate_from">"Не переводити з "</string>
<string name="error_parsing_preview_for">"Не вдалося створити перегляд для %1$s : %2$s"</string>
<string name="preview_card_image_for">"Перегляд для %1$s"</string>
<string name="new_channel">Новий канал</string>
@@ -152,12 +151,12 @@
<string name="public_chat">Публічний чат</string>
<string name="posts_received">дописів отримано</string>
<string name="remove">Видалити</string>
<string name="auto">Автоматично</string>
<string name="translated_from">перекладено з</string>
<string name="to">на</string>
<string name="show_in">Показати спершу на</string>
<string name="first" />
<string name="always_translate_to">Завжди перекладати на</string>
<string name="translations_auto">Автоматично</string>
<string name="translations_translated_from">перекладено з</string>
<string name="translations_to">на</string>
<string name="translations_show_in_lang_first">Показати спершу на %1$s</string>
<string name="translations_always_translate_to_lang">Завжди перекладати на %1$s</string>
<string name="translations_never_translate_from_lang">Не переводити з %1$s</string>
<string name="never">ніколи</string>
<string name="now">зараз</string>
<string name="h">год</string>
+6 -8
View File
@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感謝!</string>
<string name="amount_in_sats">聰數量</string>
<string name="send_sats">發送聰</string>
<string name="never_translate_from">"不再翻譯"</string>
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
<string name="new_channel">新頻道</string>
@@ -150,16 +149,15 @@
<string name="unfollow">取關</string>
<string name="channel_created">頻道已創建</string>
<string name="channel_information_changed_to">頻道信息已更改為</string>
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自動</string>
<string name="translated_from"></string>
<string name="to">更改為</string>
<string name="show_in">先顯示</string>
<string name="first">在前</string>
<string name="always_translate_to">總是翻譯為</string>
<string name="translations_auto">自動</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改為</string>
<string name="translations_show_in_lang_first">先顯示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
<string name="never">從不</string>
<string name="now">現在</string>
<string name="h">小時</string>
+6 -8
View File
@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感謝!</string>
<string name="amount_in_sats">數量 (sats)</string>
<string name="send_sats">發送 sats</string>
<string name="never_translate_from">"不再翻譯"</string>
<string name="error_parsing_preview_for">"解析 %1$s 預覽出錯:%2$s"</string>
<string name="preview_card_image_for">"預覽 %1$s 卡片圖片"</string>
<string name="new_channel">新頻道</string>
@@ -150,16 +149,15 @@
<string name="unfollow">取關</string>
<string name="channel_created">頻道已創建</string>
<string name="channel_information_changed_to">頻道信息已更改為</string>
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自動</string>
<string name="translated_from"></string>
<string name="to">更改為</string>
<string name="show_in">顯示</string>
<string name="first">在前</string>
<string name="always_translate_to">總是翻譯為</string>
<string name="translations_auto">自動</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改為</string>
<string name="translations_show_in_lang_first">顯示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">總是翻譯為 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻譯 %1$s</string>
<string name="never">從不</string>
<string name="now">現在</string>
<string name="h">小時</string>
+6 -7
View File
@@ -55,7 +55,6 @@
<string name="thank_you_so_much">非常感谢!</string>
<string name="amount_in_sats">聪数量</string>
<string name="send_sats">发送聪</string>
<string name="never_translate_from">"不再翻译"</string>
<string name="error_parsing_preview_for">"解析 %1$s 预览出错:%2$s"</string>
<string name="preview_card_image_for">"预览 %1$s 卡片图片"</string>
<string name="new_channel">新频道</string>
@@ -153,12 +152,12 @@
<string name="public_chat">公共聊天</string>
<string name="posts_received">收到文章</string>
<string name="remove">移除</string>
<string name="auto">自动</string>
<string name="translated_from"></string>
<string name="to">更改为</string>
<string name="show_in">先显示</string>
<string name="first">在前</string>
<string name="always_translate_to">总是翻译为</string>
<string name="translations_auto">自动</string>
<string name="translations_translated_from"></string>
<string name="translations_to">更改为</string>
<string name="translations_show_in_lang_first">先显示 %1$s 在前</string>
<string name="translations_always_translate_to_lang">总是翻译为 %1$s</string>
<string name="translations_never_translate_from_lang">不再翻译 %1$s</string>
<string name="never">从不</string>
<string name="now">现在</string>
<string name="h">小时</string>
+41 -24
View File
@@ -58,7 +58,6 @@
<string name="thank_you_so_much">Thank you so much!</string>
<string name="amount_in_sats">Amount in Sats</string>
<string name="send_sats">Send Sats</string>
<string name="never_translate_from">"Never translate from "</string>
<string name="error_parsing_preview_for">"Error parsing preview for %1$s : %2$s"</string>
<string name="preview_card_image_for">"Preview Card Image for %1$s"</string>
<string name="new_channel">New Channel</string>
@@ -157,12 +156,12 @@
<string name="posts_received">posts received</string>
<string name="remove">Remove</string>
<string name="sats" translatable="false">sats</string>
<string name="auto">Auto</string>
<string name="translated_from">translated from</string>
<string name="to">to</string>
<string name="show_in">Show in</string>
<string name="first">first</string>
<string name="always_translate_to">Always translate to</string>
<string name="translations_auto">Auto</string>
<string name="translations_translated_from">translated from</string>
<string name="translations_to">to</string>
<string name="translations_show_in_lang_first">Show in %1$s first</string>
<string name="translations_always_translate_to_lang">Always translate to %1$s</string>
<string name="translations_never_translate_from_lang">Never translate from %1$s</string>
<string name="nip_05" translatable="false">NIP-05</string>
<string name="lnurl" translatable="false">LNURL...</string>
<string name="never">never</string>
@@ -198,6 +197,23 @@
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">Copied authors @npub to clipboard</string>
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">Copied note ID (@note1) to clipboard</string>
<string name="select_text_dialog_top">Select Text</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Select Account</string>
<string name="account_switch_add_account_btn">Add New Account</string>
<string name="account_switch_active_account">Active account</string>
<string name="account_switch_has_private_key">Has private key</string>
<string name="account_switch_pubkey_only">Read only, no private key</string>
<string name="back">Back</string>
<string name="quick_action_select">Select</string>
<string name="quick_action_share_browser_link">Share Browser Link</string>
<string name="quick_action_share">Share</string>
@@ -209,24 +225,25 @@
<string name="quick_action_follow">Follow</string>
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
<string name="quick_action_request_deletion_alert_body">Amethyst will request that your note be deleted from the relays you are currently connected to. There is no guarantee that your note will be permanently deleted from those relays, or from other relays where it may be stored.</string>
<string name="github" translatable="false">Github Gist w/ Proof</string>
<string name="telegram" translatable="false">Telegram</string>
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
<string name="github_proof_url_template" translatable="false">https://gist.github.com/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Unable to decrypt private message&gt;\n\nYou were cited in a private/encrypted conversation between %1$s and %2$s."</string>
<string name="quick_action_block_dialog_btn">Block</string>
<string name="quick_action_delete_dialog_btn">Delete</string>
<string name="quick_action_block">Block</string>
<string name="quick_action_report">Report</string>
<string name="quick_action_delete_button">Delete</string>
<string name="quick_action_dont_show_again_button">Don\'t show again</string>
<string name="account_switch_add_account_dialog_title">Add New Account</string>
<string name="drawer_accounts">Accounts</string>
<string name="account_switch_select_account">Select Account</string>
<string name="account_switch_add_account_btn">Add New Account</string>
<string name="account_switch_active_account">Active account</string>
<string name="account_switch_has_private_key">Has private key</string>
<string name="account_switch_pubkey_only">Read only, no private key</string>
<string name="back">Back</string>
<string name="report_dialog_spam">Spam or scams</string>
<string name="report_dialog_profanity">Profanity or hateful conduct</string>
<string name="report_dialog_impersonation">Malicious impersonation</string>
<string name="report_dialog_nudity">Nudity or graphic content</string>
<string name="report_dialog_illegal">Illegal Behavior</string>
<string name="report_dialog_blocking_a_user">Blocking a user will hide their content in your app. Your notes are still publicly viewable, including to people you block. Blocked users are listed on the Security Filters screen.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Block & Hide User]]></string>
<string name="report_dialog_report_btn">Report Abuse</string>
<string name="report_dialog_reminder_public">All reports posted will be publicly visible.</string>
<string name="report_dialog_additional_reason_placeholder">Optionally provide additional context about your report…</string>
<string name="report_dialog_additional_reason_label">Additional Context</string>
<string name="report_dialog_select_reason_label">Reason</string>
<string name="report_dialog_select_reason_placeholder">Select a reason…</string>
<string name="report_dialog_post_report_btn">Post Report</string>
</resources>
@@ -111,17 +111,17 @@ fun TranslateableRichTextViewer(
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.auto))
append(stringResource(R.string.translations_auto))
}
append("-${stringResource(R.string.translated_from)} ")
append("-${stringResource(R.string.translations_translated_from)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" ${stringResource(R.string.to)} ")
append(" ${stringResource(R.string.translations_to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
@@ -166,7 +166,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.never_translate_from) + Locale(source).displayName)
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
}
Divider()
DropdownMenuItem(onClick = {
@@ -185,13 +185,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(
"${stringResource(R.string.show_in)} ${Locale(source).displayName} ${
stringResource(
R.string.first
)
}"
)
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
accountViewModel.prefer(source, target, target)
@@ -209,13 +203,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(
"${stringResource(R.string.show_in)} ${Locale(target).displayName} ${
stringResource(
R.string.first
)
}"
)
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
}
Divider()
@@ -239,7 +227,7 @@ fun TranslateableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text("${stringResource(R.string.always_translate_to)}${lang.displayName}")
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package android.util;
public class Log {
public static int d(String tag, String msg) {
System.out.println("DEBUG: " + tag + ": " + msg);
return 0;
}
public static int i(String tag, String msg) {
System.out.println("INFO: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg) {
System.out.println("WARN: " + tag + ": " + msg);
return 0;
}
public static int w(String tag, String msg, Throwable e) {
System.out.println("WARN: " + tag + ": " + msg);
e.printStackTrace();
return 0;
}
public static int e(String tag, String msg) {
System.out.println("ERROR: " + tag + ": " + msg);
return 0;
}
public static int e(String tag, String msg, Throwable e) {
System.out.println("ERROR: " + tag + ": " + msg);
e.printStackTrace();
return 0;
}
}