mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0272073636 | ||
|
|
063ef1e3a9 | ||
|
|
4ec7af3265 | ||
|
|
f09f472f26 | ||
|
|
2e365b67ea | ||
|
|
ee753e5c95 | ||
|
|
8b53d28721 | ||
|
|
f8bb4a3168 | ||
|
|
1b8923b6d7 | ||
|
|
675b59c0d2 | ||
|
|
a71078f90f | ||
|
|
d4be271060 | ||
|
|
2d59baf898 | ||
|
|
18f651b67f | ||
|
|
f65e09f388 | ||
|
|
a4bb01ef7d | ||
|
|
e9c0d350e1 | ||
|
|
faa29675db | ||
|
|
96990c44a6 | ||
|
|
424fe5fd3c | ||
|
|
06d3784261 | ||
|
|
9e3c08ade8 | ||
|
|
3b7fc7173d | ||
|
|
105c7b9fe8 | ||
|
|
c0c45d1385 | ||
|
|
9b2ce25668 | ||
|
|
7e2b28503e | ||
|
|
d798db7cf1 | ||
|
|
aa2f718589 | ||
|
|
f3a4a7e143 | ||
|
|
732abb70f3 | ||
|
|
8c98a8849c | ||
|
|
5ce6d06920 | ||
|
|
bb6b4c208d | ||
|
|
ea63522745 | ||
|
|
698c9b00cb | ||
|
|
1c740d4d7a | ||
|
|
0491c25e9f | ||
|
|
75ed380449 | ||
|
|
2820197905 | ||
|
|
01818ff458 | ||
|
|
d19592aef3 | ||
|
|
9c4f9fce37 | ||
|
|
c06ed8d338 | ||
|
|
0f42c2707e | ||
|
|
5c518501ab | ||
|
|
887d963c5b | ||
|
|
0eb21a6650 | ||
|
|
9f6867b6ab | ||
|
|
6403bd21f8 | ||
|
|
b3f2a03295 | ||
|
|
27b2cda14f | ||
|
|
f61ec0b5ab | ||
|
|
53d692ec7f | ||
|
|
12876cdacd | ||
|
|
63a14b7409 | ||
|
|
cd5e0187e8 | ||
|
|
02d74c611a | ||
|
|
a367bf5982 | ||
|
|
d8c2e623d1 | ||
|
|
70434eb244 | ||
|
|
85d722f96d | ||
|
|
e4cec20d1a | ||
|
|
bb7dcbdb78 | ||
|
|
260ed29309 | ||
|
|
9fc1fa9f6c | ||
|
|
199f9fbe69 | ||
|
|
2fea7dac62 | ||
|
|
90147ce557 | ||
|
|
4fa584ca1a | ||
|
|
7630e07dc9 | ||
|
|
5da70a0f95 | ||
|
|
be19d0a9cf | ||
|
|
20431a87ea | ||
|
|
cc36dcffe0 | ||
|
|
32b50418de | ||
|
|
b1cb7d599f | ||
|
|
f34d79106d | ||
|
|
269197a59d | ||
|
|
992796a7bf | ||
|
|
e472a494a1 | ||
|
|
b40bde10a0 | ||
|
|
9561261bf4 | ||
|
|
3a2403b344 | ||
|
|
b7f8241a08 | ||
|
|
907aa7b734 | ||
|
|
74336d72c0 | ||
|
|
0b9b288146 | ||
|
|
b7deaaa1a6 | ||
|
|
9dc9d7d947 | ||
|
|
a69a5b1a1e | ||
|
|
5842a84309 | ||
|
|
332ddc15f7 | ||
|
|
30f4ff9d6f | ||
|
|
d166f22c58 | ||
|
|
bee361cea7 | ||
|
|
31e724662d | ||
|
|
6109131ef5 | ||
|
|
fe9798fdfc | ||
|
|
a8dc03c477 | ||
|
|
d4cee941ef | ||
|
|
9adb376198 | ||
|
|
ae6cf15768 | ||
|
|
ccf2b06b10 | ||
|
|
f2f53c66bd | ||
|
|
b93f28f09d | ||
|
|
38df1ba6f7 | ||
|
|
6c9aa9b6ff | ||
|
|
26b581b13c | ||
|
|
6a3cae1c8d | ||
|
|
40ac31983c | ||
|
|
8c25667d0f | ||
|
|
6acc842d4f | ||
|
|
fc4b68b264 | ||
|
|
9470560002 | ||
|
|
cd950ee946 | ||
|
|
f9f68e2a4f | ||
|
|
3fd656da9f |
114
.github/workflows/create-release.yml
vendored
114
.github/workflows/create-release.yml
vendored
@@ -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
|
||||
60
.gitignore
vendored
60
.gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
@@ -11,9 +9,7 @@
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
|
||||
# Built application files
|
||||
@@ -36,6 +32,7 @@ out/
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
@@ -48,25 +45,50 @@ proguard/
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
# Android Studio
|
||||
/*/build/
|
||||
/*/local.properties
|
||||
/*/out
|
||||
/*/*/build
|
||||
/*/*/production
|
||||
captures/
|
||||
.navigation/
|
||||
*.ipr
|
||||
*~
|
||||
*.swp
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
*.iws
|
||||
/out/
|
||||
deploymentTargetDropdown.xml
|
||||
render.experimental.xml
|
||||
|
||||
# User-specific configurations
|
||||
.idea/**/caches/
|
||||
.idea/**/libraries/
|
||||
.idea/**/shelf/
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/.name
|
||||
.idea/**/compiler.xml
|
||||
.idea/**/copyright/profiles_settings.xml
|
||||
.idea/**/encodings.xml
|
||||
.idea/**/misc.xml
|
||||
.idea/**/modules.xml
|
||||
.idea/**/scopes/scope_settings.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/vcs.xml
|
||||
.idea/**/jsLibraryMappings.xml
|
||||
.idea/**/datasources.xml
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/assetWizardSettings.xml
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/jarRepositories.xml
|
||||
.idea/**/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
|
||||
6
.idea/compiler.xml
generated
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 91
|
||||
versionName "0.24.2"
|
||||
versionCode 98
|
||||
versionName "0.26.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}'
|
||||
@@ -66,10 +92,10 @@ dependencies {
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
|
||||
// Observe Live data as State
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-beta02'
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
|
||||
implementation 'androidx.compose.material:material:1.4.0-beta02'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-beta02"
|
||||
implementation "androidx.compose.material:material:$compose_ui_version"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
@@ -80,9 +106,6 @@ dependencies {
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.1-alpha'
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
|
||||
@@ -100,9 +123,6 @@ dependencies {
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// Robohash for Avatars
|
||||
implementation group: 'com.github.vitorpamplona', name: 'android-robohash', version: 'master-SNAPSHOT', ext: 'aar'
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
|
||||
@@ -133,28 +153,18 @@ dependencies {
|
||||
|
||||
// For QR generation
|
||||
implementation 'com.google.zxing:core:3.5.1'
|
||||
implementation "androidx.camera:camera-camera2:1.2.1"
|
||||
implementation 'androidx.camera:camera-lifecycle:1.2.1'
|
||||
implementation 'androidx.camera:camera-view:1.2.1'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
// Markdown
|
||||
implementation "com.halilibo.compose-richtext:richtext-ui:0.16.0"
|
||||
implementation "com.halilibo.compose-richtext:richtext-ui-material:0.16.0"
|
||||
implementation "com.halilibo.compose-richtext:richtext-commonmark:0.16.0"
|
||||
|
||||
// For QR Scanning
|
||||
implementation 'com.google.mlkit:vision-common:17.3.0'
|
||||
|
||||
// Local Barcode Scanning model
|
||||
// The idea is to make it work for degoogled phones
|
||||
implementation 'com.google.mlkit:barcode-scanning:17.0.3'
|
||||
|
||||
// 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'
|
||||
@@ -171,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
|
||||
)
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:name=".Amethyst"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
@@ -22,6 +23,7 @@
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
@@ -45,6 +47,11 @@
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="fullSensor"
|
||||
tools:replace="screenOrientation" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
15
app/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
|
||||
class Amethyst : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: Amethyst
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
fun preferences(context: Context): EncryptedSharedPreferences {
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
fun preferences(npub: String? = null): EncryptedSharedPreferences {
|
||||
val context = Amethyst.instance
|
||||
val masterKey: MasterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
val preferencesName = prefsFileName(npub)
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFERENCES_NAME,
|
||||
preferencesName,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -9,55 +11,188 @@ import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
private object PrefKeys {
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
// Release mode (!BuildConfig.DEBUG) always uses encrypted preferences
|
||||
// To use plaintext SharedPreferences for debugging, set this to true
|
||||
// It will only apply in Debug builds
|
||||
private const val DEBUG_PLAINTEXT_PREFERENCES = false
|
||||
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
private val encryptedPreferences = EncryptedStorage.preferences(context)
|
||||
private val gson = GsonBuilder().create()
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean,
|
||||
val current: Boolean,
|
||||
val displayName: String?,
|
||||
val profilePicture: String?
|
||||
)
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
encryptedPreferences.all.keys.forEach { remove(it) }
|
||||
private object PrefKeys {
|
||||
const val CURRENT_ACCOUNT = "currently_logged_in_account"
|
||||
const val SAVED_ACCOUNTS = "all_saved_accounts"
|
||||
const val NOSTR_PRIVKEY = "nostr_privkey"
|
||||
const val NOSTR_PUBKEY = "nostr_pubkey"
|
||||
const val DISPLAY_NAME = "display_name"
|
||||
const val PROFILE_PICTURE_URL = "profile_picture"
|
||||
const val FOLLOWING_CHANNELS = "following_channels"
|
||||
const val HIDDEN_USERS = "hidden_users"
|
||||
const val RELAYS = "relays"
|
||||
const val DONT_TRANSLATE_FROM = "dontTranslateFrom"
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_INFO = "hideDeleteRequestInfo"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
private val gson = GsonBuilder().create()
|
||||
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
private var currentAccount: String?
|
||||
get() = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
set(npub) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.CURRENT_ACCOUNT, npub)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private val savedAccounts: List<String>
|
||||
get() = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private fun addAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
if (npub !in accounts) {
|
||||
accounts.add(npub)
|
||||
}
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun setCurrentAccount(account: Account) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
currentAccount = npub
|
||||
addAccount(npub)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
currentAccount = npub
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the account from the app level shared preferences
|
||||
*/
|
||||
private fun removeAccount(npub: String) {
|
||||
val accounts = savedAccounts.toMutableList()
|
||||
if (accounts.remove(npub)) {
|
||||
val prefs = encryptedPreferences()
|
||||
prefs.edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the npub-specific shared preference file
|
||||
*/
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
|
||||
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||
} else {
|
||||
return EncryptedStorage.preferences(npub)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the preferences for a given npub, deletes the preferences xml file,
|
||||
* and switches the user to the first account in the list if it exists
|
||||
*
|
||||
* We need to use `commit()` to write changes to disk and release the file
|
||||
* lock so that it can be deleted. If we use `apply()` there is a race
|
||||
* condition and the file will probably not be deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
fun updatePrefsForLogout(npub: String) {
|
||||
val userPrefs = encryptedPreferences(npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
|
||||
if (savedAccounts.isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount == npub) {
|
||||
currentAccount = savedAccounts.elementAt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts.map { npub ->
|
||||
val prefs = encryptedPreferences(npub)
|
||||
val hasPrivKey = prefs.getString(PrefKeys.NOSTR_PRIVKEY, null) != null
|
||||
|
||||
AccountInfo(
|
||||
npub = npub,
|
||||
hasPrivKey = hasPrivKey,
|
||||
current = npub == currentAccount,
|
||||
displayName = prefs.getString(PrefKeys.DISPLAY_NAME, null),
|
||||
profilePicture = prefs.getString(PrefKeys.PROFILE_PICTURE_URL, null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHex()) }
|
||||
account.followingChannels.let { putStringSet(PrefKeys.FOLLOWING_CHANNELS, it) }
|
||||
account.hiddenUsers.let { putStringSet(PrefKeys.HIDDEN_USERS, it) }
|
||||
account.localRelays.let { putString(PrefKeys.RELAYS, gson.toJson(it)) }
|
||||
account.dontTranslateFrom.let { putStringSet(PrefKeys.DONT_TRANSLATE_FROM, it) }
|
||||
account.languagePreferences.let { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(it)) }
|
||||
account.translateTo.let { putString(PrefKeys.TRANSLATE_TO, it) }
|
||||
account.zapAmountChoices.let { putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(it)) }
|
||||
account.backupContactList.let { putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(it)) }
|
||||
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||
putString(PrefKeys.RELAYS, gson.toJson(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
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)
|
||||
putString(PrefKeys.DISPLAY_NAME, account.userProfile().toBestDisplayName())
|
||||
putString(PrefKeys.PROFILE_PICTURE_URL, account.userProfile().profilePicture())
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
encryptedPreferences(currentAccount).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null)
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
val localRelays = gson.fromJson(
|
||||
@@ -75,7 +210,8 @@ class LocalPreferences(context: Context) {
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
.getRefinedEvent(true) as ContactListEvent
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
@@ -84,43 +220,83 @@ class LocalPreferences(context: Context) {
|
||||
|
||||
val languagePreferences = try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
gson.fromJson(it, object : TypeToken<Map<String, String>>() {}.type) as Map<String, String>
|
||||
} ?: mapOf<String, String>()
|
||||
gson.fromJson(
|
||||
it,
|
||||
object : TypeToken<Map<String, String>>() {}.type
|
||||
) as Map<String, String>
|
||||
} ?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
mapOf<String, String>()
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val hideDeleteRequestInfo = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_INFO, false)
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
hideDeleteRequestInfo,
|
||||
latestContactList
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
dontTranslateFrom,
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
hideDeleteRequestInfo,
|
||||
latestContactList
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
encryptedPreferences(currentAccount).edit().apply {
|
||||
putLong(PrefKeys.LAST_READ(route), timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
encryptedPreferences(currentAccount).run {
|
||||
return getLong(PrefKeys.LAST_READ(route), 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun migrateSingleUserPrefs() {
|
||||
if (currentAccount != null) return
|
||||
|
||||
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
|
||||
val npub = Hex.decode(pubkey).toNpub()
|
||||
|
||||
val stringPrefs = listOf(
|
||||
PrefKeys.NOSTR_PRIVKEY,
|
||||
PrefKeys.NOSTR_PUBKEY,
|
||||
PrefKeys.RELAYS,
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
PrefKeys.TRANSLATE_TO,
|
||||
PrefKeys.ZAP_AMOUNTS,
|
||||
PrefKeys.LATEST_CONTACT_LIST
|
||||
)
|
||||
|
||||
val stringSetPrefs = listOf(
|
||||
PrefKeys.FOLLOWING_CHANNELS,
|
||||
PrefKeys.HIDDEN_USERS,
|
||||
PrefKeys.DONT_TRANSLATE_FROM
|
||||
)
|
||||
|
||||
encryptedPreferences().apply {
|
||||
val appPrefs = this
|
||||
encryptedPreferences(npub).edit().apply {
|
||||
val userPrefs = this
|
||||
|
||||
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)
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
addAccount(npub)
|
||||
currentAccount = npub
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -14,23 +13,23 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
|
||||
fun markAsRead(route: String, timestampInSecs: Long) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
LocalPreferences.saveLastRead(route, timestampInSecs)
|
||||
live.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(route: String, context: Context): Long {
|
||||
fun load(route: String): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences(context).loadLastRead(route)
|
||||
lastTime = LocalPreferences.loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.util.LruCache
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import name.neuhalfen.projects.android.robohash.buckets.VariableSizeHashing
|
||||
import name.neuhalfen.projects.android.robohash.handle.Handle
|
||||
import name.neuhalfen.projects.android.robohash.handle.HandleFactory
|
||||
import name.neuhalfen.projects.android.robohash.paths.Configuration
|
||||
import name.neuhalfen.projects.android.robohash.repository.ImageRepository
|
||||
import java.util.UUID
|
||||
|
||||
object RoboHashCache {
|
||||
|
||||
lateinit var robots: MyRoboHash
|
||||
|
||||
lateinit var defaultAvatar: ImageBitmap
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, hash: String): ImageBitmap {
|
||||
if (!this::robots.isInitialized) {
|
||||
robots = MyRoboHash(context)
|
||||
|
||||
defaultAvatar = robots.imageForHandle(robots.calculateHandleFromUUID(UUID.nameUUIDFromBytes("aaaa".toByteArray()))).asImageBitmap()
|
||||
}
|
||||
|
||||
return defaultAvatar
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates RoboHash to use a custom configuration
|
||||
*/
|
||||
class MyRoboHash(context: Context) {
|
||||
private val configuration: Configuration = ModifiedSet1Configuration()
|
||||
private val repository: ImageRepository
|
||||
private val hashing = VariableSizeHashing(configuration.bucketSizes)
|
||||
|
||||
// Optional
|
||||
private var memoryCache: LruCache<String, Bitmap>? = null
|
||||
|
||||
init {
|
||||
repository = ImageRepository(context.assets)
|
||||
}
|
||||
|
||||
fun useCache(memoryCache: LruCache<String, Bitmap>?) {
|
||||
this.memoryCache = memoryCache
|
||||
}
|
||||
|
||||
fun calculateHandleFromUUID(uuid: UUID?): Handle {
|
||||
val data = hashing.createBuckets(uuid)
|
||||
return handleFactory.calculateHandle(data)
|
||||
}
|
||||
|
||||
fun imageForHandle(handle: Handle): Bitmap {
|
||||
if (null != memoryCache) {
|
||||
val cached = memoryCache!![handle.toString()]
|
||||
if (null != cached) return cached
|
||||
}
|
||||
val bucketValues = handle.bucketValues()
|
||||
val paths = configuration.convertToFacetParts(bucketValues)
|
||||
val sampleSize = 1
|
||||
val buffer = repository.createBuffer(configuration.width(), configuration.height())
|
||||
val target = buffer.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val merged = Canvas(target)
|
||||
val paint = Paint(0)
|
||||
|
||||
// The first image is not added as copy form the buffer
|
||||
for (i in paths.indices) {
|
||||
merged.drawBitmap(repository.getInto(buffer, paths[i], sampleSize), 0f, 0f, paint)
|
||||
}
|
||||
repository.returnBuffer(buffer)
|
||||
if (null != memoryCache) {
|
||||
memoryCache!!.put(handle.toString(), target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val handleFactory = HandleFactory()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom configuration to avoid the use of String.format in the GeneratePath
|
||||
* This uses the default location and ends up encoding number in the local language
|
||||
*/
|
||||
class ModifiedSet1Configuration : Configuration {
|
||||
override fun convertToFacetParts(bucketValues: ByteArray): Array<String> {
|
||||
require(bucketValues.size == BUCKET_COUNT)
|
||||
val color = INT_TO_COLOR[bucketValues[BUCKET_COLOR].toInt()]
|
||||
val paths = mutableListOf<String>()
|
||||
|
||||
// e.g.
|
||||
// blue face #2
|
||||
// blue nose #7
|
||||
// blue
|
||||
val firstFacetBucket = BUCKET_COLOR + 1
|
||||
for (facet in 0 until FACET_COUNT) {
|
||||
val bucketValue = bucketValues[firstFacetBucket + facet].toInt()
|
||||
paths.add(generatePath(FACET_PATH_TEMPLATES[facet], color, bucketValue))
|
||||
}
|
||||
return paths.toTypedArray()
|
||||
}
|
||||
|
||||
private fun generatePath(facetPathTemplate: String, color: String, bucketValue: Int): String {
|
||||
// TODO: Make more efficient
|
||||
return facetPathTemplate.replace("#ROOT#", ROOT).replace("#COLOR#".toRegex(), color)
|
||||
.replace("#ITEM#".toRegex(), (bucketValue + 1).toString().padStart(2, '0'))
|
||||
}
|
||||
|
||||
override fun getBucketSizes(): ByteArray {
|
||||
return BUCKET_SIZES
|
||||
}
|
||||
|
||||
override fun width(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
override fun height(): Int {
|
||||
return 300
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ROOT = "sets/set1"
|
||||
private const val BUCKET_COLOR = 0
|
||||
private const val COLOR_COUNT = 10
|
||||
private const val BODY_COUNT = 10
|
||||
private const val FACE_COUNT = 10
|
||||
private const val MOUTH_COUNT = 10
|
||||
private const val EYES_COUNT = 10
|
||||
private const val ACCESSORY_COUNT = 10
|
||||
private const val BUCKET_COUNT = 6
|
||||
private const val FACET_COUNT = 5
|
||||
private val BUCKET_SIZES = byteArrayOf(
|
||||
COLOR_COUNT.toByte(),
|
||||
BODY_COUNT.toByte(),
|
||||
FACE_COUNT.toByte(),
|
||||
MOUTH_COUNT.toByte(),
|
||||
EYES_COUNT.toByte(),
|
||||
ACCESSORY_COUNT.toByte()
|
||||
)
|
||||
private val INT_TO_COLOR = arrayOf(
|
||||
"blue",
|
||||
"brown",
|
||||
"green",
|
||||
"grey",
|
||||
"orange",
|
||||
"pink",
|
||||
"purple",
|
||||
"red",
|
||||
"white",
|
||||
"yellow"
|
||||
)
|
||||
private val FACET_PATH_TEMPLATES = arrayOf(
|
||||
"#ROOT#/#COLOR#/01Body/#COLOR#_body-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/02Face/#COLOR#_face-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Mouth/#COLOR#_mouth-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Eyes/#COLOR#_eyes-#ITEM#.png",
|
||||
"#ROOT#/#COLOR#/Accessory/#COLOR#_accessory-#ITEM#.png"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -48,6 +49,7 @@ fun getLanguagesSpokenByUser(): Set<String> {
|
||||
return codedList
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
var followingChannels: Set<String> = DefaultChannels,
|
||||
@@ -152,7 +154,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
|
||||
@@ -165,7 +167,7 @@ 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) {
|
||||
@@ -248,7 +250,7 @@ class Account(
|
||||
val event = if (contactList != null && follows.isNotEmpty()) {
|
||||
ContactListEvent.create(
|
||||
follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
} else {
|
||||
@@ -273,7 +275,7 @@ class Account(
|
||||
if (contactList != null && follows.isNotEmpty()) {
|
||||
val event = ContactListEvent.create(
|
||||
follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
userProfile().relays,
|
||||
contactList.relays(),
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -317,7 +319,7 @@ class Account(
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
|
||||
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null) {
|
||||
if (!isWriteable()) return
|
||||
val user = LocalCache.users[toUser] ?: return
|
||||
|
||||
@@ -452,7 +454,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) {
|
||||
@@ -463,7 +465,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
|
||||
@@ -499,15 +501,23 @@ class Account(
|
||||
|
||||
fun isHidden(user: User) = user.pubkeyHex in hiddenUsers || user.pubkeyHex in transientHiddenUsers
|
||||
|
||||
fun followingKeySet(): Set<HexKey> {
|
||||
return userProfile().latestContactList?.verifiedFollowKeySet ?: 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 {
|
||||
@@ -520,7 +530,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()
|
||||
@@ -530,8 +540,9 @@ class Account(
|
||||
|
||||
return (
|
||||
note.reportsBy(followsPlusMe) +
|
||||
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
|
||||
innerReports
|
||||
(
|
||||
note.author?.reportsBy(followsPlusMe) ?: emptyList()
|
||||
) + innerReports
|
||||
).toSet()
|
||||
}
|
||||
|
||||
@@ -573,7 +584,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
@@ -16,7 +15,6 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
@@ -29,13 +27,7 @@ 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.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.time.Instant
|
||||
@@ -57,13 +49,10 @@ object LocalCache {
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>()
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNpub() // Checks if this is a valid Hex
|
||||
getOrCreateUser(key)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
null
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateUser(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -79,13 +68,10 @@ object LocalCache {
|
||||
if (ATag.isATag(key)) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
|
||||
getOrCreateNote(key)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create note: $key", e)
|
||||
null
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateNote(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -98,12 +84,19 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateChannel(key)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun isValidHexNpub(key: String): Boolean {
|
||||
return try {
|
||||
val checkHex = Hex.decode(key).toNote() // Checks if this is a valid Hex
|
||||
getOrCreateChannel(key)
|
||||
Hex.decode(key).toNpub()
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e("LocalCache", "Invalid Key to create channel: $key", e)
|
||||
null
|
||||
Log.e("LocalCache", "Invalid Key to create user: $key", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,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)
|
||||
@@ -233,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()
|
||||
}
|
||||
}
|
||||
@@ -261,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()
|
||||
}
|
||||
@@ -278,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)
|
||||
|
||||
@@ -298,12 +273,7 @@ object LocalCache {
|
||||
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 {
|
||||
@@ -313,94 +283,18 @@ 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())
|
||||
// // Log.d("RR", event.toJson())
|
||||
}
|
||||
|
||||
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}")
|
||||
}
|
||||
@@ -422,10 +316,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)
|
||||
@@ -444,13 +338,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
|
||||
@@ -482,23 +374,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)
|
||||
@@ -518,18 +401,10 @@ object LocalCache {
|
||||
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 == "+" ||
|
||||
@@ -569,7 +444,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.
|
||||
@@ -585,21 +460,19 @@ object LocalCache {
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
if (event.createdAt <= oldChannel.updatedMetadataAt) {
|
||||
return // older data, does nothing
|
||||
}
|
||||
if (oldChannel.creator == null || oldChannel.creator == author) {
|
||||
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList(), emptyList())
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
} else {
|
||||
// older data, does nothing
|
||||
refreshObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +490,7 @@ object LocalCache {
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
oldChannel.addNote(note)
|
||||
note.loadEvent(event, author, emptyList(), emptyList())
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
@@ -658,18 +531,10 @@ object LocalCache {
|
||||
.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)
|
||||
@@ -678,9 +543,11 @@ object LocalCache {
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: ChannelHideMessageEvent) {
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: ChannelMuteUserEvent) {
|
||||
}
|
||||
|
||||
@@ -696,9 +563,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()}}")
|
||||
@@ -707,14 +577,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)
|
||||
}
|
||||
@@ -734,18 +596,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)
|
||||
}
|
||||
@@ -795,7 +649,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
channels.forEach {
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
toBeRemoved.forEach {
|
||||
@@ -803,15 +657,12 @@ 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 { replyingNote ->
|
||||
it.replyTo?.forEach { _ ->
|
||||
it.removeReply(it)
|
||||
}
|
||||
}
|
||||
@@ -820,40 +671,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())
|
||||
@@ -866,14 +683,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)
|
||||
@@ -897,7 +706,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()
|
||||
|
||||
@@ -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, followedAt: Long) {
|
||||
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>, followedAt: Long) {
|
||||
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()
|
||||
@@ -254,22 +211,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, updateAt)
|
||||
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 +236,29 @@ 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 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 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 {
|
||||
|
||||
@@ -25,10 +25,10 @@ class Nip05Verifier {
|
||||
return null
|
||||
}
|
||||
|
||||
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fun fetchNip05Json(nip05address: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
|
||||
fetchNip05JsonSuspend(nip05address, onSuccess, onError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ class Nip05Verifier {
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -18,6 +19,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
@@ -27,6 +29,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
super.start()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
@@ -37,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))
|
||||
|
||||
@@ -51,7 +51,10 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
@@ -91,7 +94,10 @@ class LightningAddressResolver {
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
@@ -127,9 +133,9 @@ class LightningAddressResolver {
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
onSuccess = { lnAddressJson ->
|
||||
val lnurlp = try {
|
||||
mapper.readTree(it)
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
|
||||
null
|
||||
@@ -143,9 +149,9 @@ class LightningAddressResolver {
|
||||
|
||||
val allowsNostr = lnurlp?.get("allowsNostr")?.asBoolean() ?: false
|
||||
|
||||
callback?.let { callback ->
|
||||
callback?.let { cb ->
|
||||
fetchLightningInvoice(
|
||||
callback,
|
||||
cb,
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else 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)
|
||||
|
||||
@@ -18,17 +18,16 @@ class ChannelHideMessageEvent(
|
||||
companion object {
|
||||
const val kind = 43
|
||||
|
||||
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
|
||||
val content = reason
|
||||
fun create(reason: String, messagesToHide: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags =
|
||||
messagesToHide?.map {
|
||||
listOf("e", it)
|
||||
} ?: emptyList()
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, reason)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
return ChannelHideMessageEvent(id.toHexKey(), pubKey, createdAt, tags, reason, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,21 +18,44 @@ 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 verifiedFollowKeySetAndMe: Set<HexKey> by lazy {
|
||||
verifiedFollowKeySet + pubKey
|
||||
}
|
||||
|
||||
fun unverifiedFollowKeySet() = tags.filter { it[0] == "p" }.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 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
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
|
||||
@@ -22,4 +22,6 @@ interface EventInterface {
|
||||
fun checkSignature()
|
||||
|
||||
fun hasValidSignature(): Boolean
|
||||
|
||||
fun isTaggedUser(loggedInUser: String): Boolean
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@ abstract class IdentityClaim(
|
||||
|
||||
companion object {
|
||||
fun create(platformIdentity: String, proof: String): IdentityClaim? {
|
||||
val platformIdentity = platformIdentity.split(':')
|
||||
val platform = platformIdentity[0]
|
||||
val identity = platformIdentity[1]
|
||||
val (platform, identity) = platformIdentity.split(':')
|
||||
|
||||
return when (platform.lowercase()) {
|
||||
GitHubIdentity.platform -> GitHubIdentity(identity, proof)
|
||||
@@ -128,7 +126,7 @@ class MastodonIdentity(
|
||||
if (proofUrl.isBlank()) return null
|
||||
val path = proofUrl.removePrefix("https://").split("?")[0].split("/")
|
||||
|
||||
return MastodonIdentity(path[0], path[1])
|
||||
return MastodonIdentity("${path[0]}/${path[1]}", path[2])
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -169,16 +167,16 @@ class MetadataEvent(
|
||||
}
|
||||
|
||||
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
val content = contactMetaData
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
identities?.forEach {
|
||||
|
||||
identities.forEach {
|
||||
tags.add(listOf("i", it.platformIdentity(), it.proof))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, contactMetaData)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
return MetadataEvent(id.toHexKey(), pubKey, createdAt, tags, contactMetaData, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ class ReportEvent(
|
||||
|
||||
private fun defaultReportType(): ReportType {
|
||||
// Works with old and new structures for report.
|
||||
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
var reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
|
||||
if (reportType == null) {
|
||||
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.toUpperCase()) }.firstOrNull()
|
||||
reportType = tags.mapNotNull { it.getOrNull(2) }.map { ReportType.valueOf(it.uppercase()) }.firstOrNull()
|
||||
}
|
||||
if (reportType == null) {
|
||||
reportType = ReportType.SPAM
|
||||
@@ -34,7 +34,7 @@ class ReportEvent(
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class ReportEvent(
|
||||
.map {
|
||||
ReportedKey(
|
||||
it[1],
|
||||
it.getOrNull(2)?.toUpperCase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
it.getOrNull(2)?.uppercase()?.let { it1 -> ReportType.valueOf(it1) } ?: defaultReportType()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,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
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -78,6 +79,7 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.unloadRelays()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
@@ -86,6 +88,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onError(error: Error, subscriptionId: String, relay: Relay) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
@@ -94,6 +97,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
@@ -102,6 +106,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
|
||||
@@ -55,7 +55,10 @@ class Relay(
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
val request = Request.Builder().url(url.trim()).build()
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url(url.trim())
|
||||
.build()
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
|
||||
@@ -27,13 +27,13 @@ class TypedFilter(
|
||||
fun filterToJson(filter: JsonFilter): JsonObject {
|
||||
val jsonObject = JsonObject()
|
||||
filter.ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
|
||||
jsonObject.add("ids", JsonArray().apply { filter.ids.forEach { add(it) } })
|
||||
}
|
||||
filter.authors?.run {
|
||||
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
|
||||
jsonObject.add("authors", JsonArray().apply { filter.authors.forEach { add(it) } })
|
||||
}
|
||||
filter.kinds?.run {
|
||||
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
|
||||
jsonObject.add("kinds", JsonArray().apply { filter.kinds.forEach { add(it) } })
|
||||
}
|
||||
filter.tags?.run {
|
||||
entries.forEach { kv ->
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -49,12 +50,14 @@ class MainActivity : FragmentActivity() {
|
||||
.build()
|
||||
}
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
AccountStateViewModel()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
@@ -65,6 +68,7 @@ class MainActivity : FragmentActivity() {
|
||||
Client.lenient = true
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaScannerConnection
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
@@ -31,6 +30,7 @@ object ImageSaver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.get()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -134,12 +134,7 @@ object ImageSaver {
|
||||
|
||||
// Call the media scanner manually, so the image
|
||||
// appears in the gallery faster.
|
||||
context.sendBroadcast(
|
||||
Intent(
|
||||
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
|
||||
outputFile.toUri()
|
||||
)
|
||||
)
|
||||
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
|
||||
}
|
||||
|
||||
private const val PICTURES_SUBDIRECTORY = "Amethyst"
|
||||
|
||||
@@ -43,8 +43,9 @@ object ImageUploader {
|
||||
.build()
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
@@ -31,8 +30,6 @@ import com.vitorpamplona.amethyst.model.Channel
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
postViewModel.load(account, channel)
|
||||
|
||||
Dialog(
|
||||
@@ -58,7 +55,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create(context)
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -25,7 +24,7 @@ class NewChannelViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun create(context: Context) {
|
||||
fun create() {
|
||||
this.account?.let { account ->
|
||||
if (originalChannel == null) {
|
||||
account.sendCreateNewChannel(
|
||||
|
||||
@@ -208,7 +208,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { index, item ->
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
@@ -218,7 +218,9 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImage
|
||||
isUploading = postViewModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.primary,
|
||||
modifier = Modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
postViewModel.upload(it, context)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,7 +40,6 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -58,12 +57,10 @@ import java.lang.Math.round
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, ctx)
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -83,13 +80,13 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.clear(ctx)
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create(ctx)
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true
|
||||
@@ -186,7 +183,7 @@ fun ServerConfigHeader() {
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = "Spam",
|
||||
text = stringResource(R.string.spam),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
@@ -18,22 +17,22 @@ class NewRelayListViewModel : ViewModel() {
|
||||
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
fun load(account: Account, ctx: Context) {
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear(ctx)
|
||||
clear()
|
||||
}
|
||||
|
||||
fun create(ctx: Context) {
|
||||
fun create() {
|
||||
relays.let {
|
||||
account.saveRelayList(it.value)
|
||||
}
|
||||
|
||||
clear(ctx)
|
||||
clear()
|
||||
}
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
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.
|
||||
|
||||
@@ -4,23 +4,38 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun UploadFromGallery(
|
||||
isUploading: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onImageChosen: (Uri) -> Unit
|
||||
) {
|
||||
val cameraPermissionState =
|
||||
@@ -43,64 +58,111 @@ fun UploadFromGallery(
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Box() {
|
||||
TextButton(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter),
|
||||
enabled = !isUploading,
|
||||
onClick = {
|
||||
showGallerySelect = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_add_photo),
|
||||
contentDescription = stringResource(id = R.string.upload_image),
|
||||
modifier = Modifier
|
||||
.height(20.dp)
|
||||
.padding(end = 8.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
|
||||
if (!isUploading) {
|
||||
Text(stringResource(R.string.upload_image))
|
||||
} else {
|
||||
Text(stringResource(R.string.uploading))
|
||||
}
|
||||
}
|
||||
}
|
||||
UploadBoxButton(isUploading, tint, modifier) {
|
||||
showGallerySelect = true
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Button(
|
||||
onClick = { cameraPermissionState.launchPermissionRequest() },
|
||||
enabled = !isUploading
|
||||
) {
|
||||
if (!isUploading) {
|
||||
Text(stringResource(R.string.upload_image))
|
||||
} else {
|
||||
Text(stringResource(R.string.uploading))
|
||||
}
|
||||
UploadBoxButton(isUploading, tint, modifier) {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadBoxButton(
|
||||
isUploading: Boolean,
|
||||
tint: Color,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box() {
|
||||
IconButton(
|
||||
modifier = modifier.align(Alignment.Center),
|
||||
enabled = !isUploading,
|
||||
onClick = {
|
||||
onClick()
|
||||
}
|
||||
) {
|
||||
if (!isUploading) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddPhotoAlternate,
|
||||
contentDescription = stringResource(id = R.string.upload_image),
|
||||
modifier = Modifier.height(25.dp),
|
||||
tint = tint
|
||||
)
|
||||
} else {
|
||||
LoadingAnimation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingAnimation(
|
||||
indicatorSize: Dp = 20.dp,
|
||||
circleColors: List<Color> = listOf(
|
||||
Color(0xFF5851D8),
|
||||
Color(0xFF833AB4),
|
||||
Color(0xFFC13584),
|
||||
Color(0xFFE1306C),
|
||||
Color(0xFFFD1D1D),
|
||||
Color(0xFFF56040),
|
||||
Color(0xFFF77737),
|
||||
Color(0xFFFCAF45),
|
||||
Color(0xFFFFDC80),
|
||||
Color(0xFF5851D8)
|
||||
),
|
||||
animationDuration: Int = 1000
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
val rotateAnimation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 360f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(
|
||||
durationMillis = animationDuration,
|
||||
easing = LinearEasing
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(size = indicatorSize)
|
||||
.rotate(degrees = rotateAnimation)
|
||||
.border(
|
||||
width = 4.dp,
|
||||
brush = Brush.sweepGradient(circleColors),
|
||||
shape = CircleShape
|
||||
),
|
||||
progress = 1f,
|
||||
strokeWidth = 1.dp,
|
||||
color = MaterialTheme.colors.background // Set background color
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GallerySelect(
|
||||
onImageUri: (Uri?) -> Unit = { }
|
||||
) {
|
||||
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
onResult = { uri: Uri? ->
|
||||
onImageUri(uri)
|
||||
hasLaunched.set(false)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LaunchGallery() {
|
||||
SideEffect {
|
||||
launcher.launch("image/*")
|
||||
if (!hasLaunched.getAndSet(true)) {
|
||||
launcher.launch("image/*")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ 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)$")
|
||||
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$")
|
||||
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]+)\\].*")
|
||||
|
||||
@@ -218,7 +218,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
|
||||
val index = try {
|
||||
matcher.find()
|
||||
matcher.group(1).toInt()
|
||||
matcher.group(1)?.toInt()
|
||||
} catch (e: Exception) {
|
||||
println("Couldn't link tag $word")
|
||||
null
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import coil.ImageLoader
|
||||
import coil.decode.DataSource
|
||||
import coil.decode.ImageSource
|
||||
import coil.fetch.Fetcher
|
||||
import coil.fetch.SourceResult
|
||||
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()
|
||||
val rgb = argb and 0x00FFFFFF // Mask out the alpha channel
|
||||
return String.format("#%06X", rgb)
|
||||
}
|
||||
|
||||
private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
private fun byteMod10(byte: Byte): Int {
|
||||
val ub = byte.toUByte().toInt()
|
||||
return ub % 10
|
||||
}
|
||||
|
||||
private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
|
||||
return Color(b1.toUByte().toInt(), b2.toUByte().toInt(), b3.toUByte().toInt())
|
||||
}
|
||||
|
||||
private fun svgString(msg: String): String {
|
||||
val hash = sha256.digest(msg.toByteArray())
|
||||
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
|
||||
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
|
||||
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
|
||||
val bodyIndex = byteMod10(hash[6])
|
||||
val faceIndex = byteMod10(hash[7])
|
||||
val eyesIndex = byteMod10(hash[8])
|
||||
val mouthIndex = byteMod10(hash[9])
|
||||
val accIndex = byteMod10(hash[10])
|
||||
val body = bodies[bodyIndex]
|
||||
val face = faces[faceIndex]
|
||||
val eye = eyes[eyesIndex]
|
||||
val mouth = mouths[mouthIndex]
|
||||
val accessory = accessories[accIndex]
|
||||
|
||||
return """<svg id="$hashHex" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-bg{fill:${toHex(bgColor)}}.cls-fill-1{fill:${toHex(fgColor)};}.cls-fill-2{fill:${toHex(fgColor)};}
|
||||
${body.style}${face.style}${eye.style}${mouth.style}${accessory.style}
|
||||
</style>
|
||||
</defs>
|
||||
<title>Robohash $hashHex</title>
|
||||
${background}${body.paths}${face.paths}${eye.paths}${mouth.paths}${accessory.paths}
|
||||
</svg>"""
|
||||
}
|
||||
|
||||
class HashImageFetcher(
|
||||
private val context: Context,
|
||||
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
|
||||
)
|
||||
}
|
||||
println("Elapsed: $elapsed")
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
class Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return HashImageFetcher(options.context, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
object Robohash {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory())
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Part(val style: String, val paths: String)
|
||||
|
||||
private const val background = """<polyline class="cls-bg" points="150.3 7.4 55.1 97.9 55.1 203.1 150.3 293.6 245.9 203.1 245.9 97.9 150.3 7.4"/>"""
|
||||
|
||||
private val accessories: List<Part> = listOf(
|
||||
Part(
|
||||
""".cls-00-2{fill:none;}.cls-00-2,.cls-00-3,.cls-00-4{stroke:#000;}.cls-00-2,.cls-00-3{stroke-linecap:round;stroke-linejoin:round;}.cls-00-3{fill-opacity:0.4;stroke-width:0.75px;}.cls-00-4{fill-opacity:0.2;stroke-miterlimit:10;stroke-width:0.5px;}""",
|
||||
"""<g id="accessory-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M141.5,79.5s-1,11-1,17v8s-11,3-15,3h-10s-1-11-1-15-2-10,6-13a138,138,0,0,1,14-1C140.5,78.5,141.5,79.5,141.5,79.5Z"/><path class="cls-00-2" d="M141.5,79.5s-1,11-1,17v8s-11,3-15,3h-10s-1-11-1-15-2-10,6-13a138,138,0,0,1,14-1C140.5,78.5,141.5,79.5,141.5,79.5Z"/><path class="cls-00-3" d="M116.5,106.5s22-2.67,24-3.33v1.33s-11.42,2.74-13.21,2.87-11.79.13-11.79.13v-.94l1-.06"/><circle cx="137.5" cy="100.5" r="1"/><circle cx="139" cy="81" r="0.75"/><circle cx="117" cy="103" r="0.75"/><path class="cls-00-4" d="M117,88h3a3.49,3.49,0,0,1,2-1c1,0,9.5-1.5,9.5-1.5s-9,7-10,13v4l7-1s-1-6,2-10a22.28,22.28,0,0,1,7-6v-3l-1-1-18,2-1,1Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-01-2{fill:#fff;fill-opacity:0.2;}.cls-01-3,.cls-01-4{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-01-4{stroke-width:0.75px;}""",
|
||||
"""<g id="accessory-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M103.5,165.5s11-4,15-6a59.46,59.46,0,0,1,7-3s5,0,6,3,0,5-1,6-23,8-23,8a4.45,4.45,0,0,1-5-2C99.5,166.5,103.5,165.5,103.5,165.5Z"/><path class="cls-01-2" d="M102,167c-1.07,1.3,1.29,6.38,2.64,6.19s6.07.81,4.36-4.12C107.18,165,103.05,165.72,102,167Z"/><path class="cls-01-3" d="M103.5,165.5s11-4,15-6a59.46,59.46,0,0,1,7-3s5,0,6,3,0,5-1,6-23,8-23,8a4.45,4.45,0,0,1-5-2C99.5,166.5,103.5,165.5,103.5,165.5Z"/><path class="cls-01-4" d="M106.5,173.5c3-1,3-3,2-5-3-4-6-2-6-2.24"/><path class="cls-01-4" d="M109.1,163.4s4.4.1,5.4,3.1a5.22,5.22,0,0,1-.78,5"/><circle cx="128" cy="161" r="1"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-02-2{fill:#be1e2d;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-6,.cls-02-7,.cls-02-9{stroke:#000;}.cls-02-11,.cls-02-2,.cls-02-5,.cls-02-9{stroke-miterlimit:10;}.cls-02-3{fill:#561317;}.cls-02-4{fill:#ed293b;}.cls-02-11,.cls-02-5,.cls-02-7{fill:none;}.cls-02-5,.cls-02-6{stroke-width:0.75px;}.cls-02-6{fill:#fff;}.cls-02-6,.cls-02-8{fill-opacity:0.2;}.cls-02-6,.cls-02-7{stroke-linecap:round;stroke-linejoin:round;}.cls-02-9{fill:#e6e7e8;}.cls-02-10{fill:#d0d2d3;}""",
|
||||
"""<g id="accessory-03"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M122.5,94.5s4,3,13,3,16-6,16-6a26.39,26.39,0,0,0-1-7c-1-3-10.49-2.87-15.74-1.93s-11.26.93-12.26,3.93Z"/><ellipse class="cls-02-2" cx="133" cy="26" rx="7.5" ry="6.5"/><path class="cls-02-3" d="M140,25c.19.68-5.51,8.12-11.57,6.27a8.65,8.65,0,0,0,7.91.53C141,30,140,25,140,25Z"/><ellipse class="cls-02-4" cx="130.22" cy="22.93" rx="4.46" ry="2.23" transform="matrix(0.87, -0.5, 0.5, 0.87, 5.98, 68.18)"/><ellipse class="cls-02-5" cx="133" cy="26" rx="7.5" ry="6.5"/><path class="cls-02-6" d="M135.5,82.5s-13,1-13,4,9,4,13,4,15-2,15-6S135.5,82.5,135.5,82.5Z"/><path class="cls-02-7" d="M122.5,94.5s4,3,13,3,16-6,16-6a26.39,26.39,0,0,0-1-7c-1-3-10.49-2.87-15.74-1.93s-11.26.93-12.26,3.93Z"/><path class="cls-02-8" d="M148.19,93.77c.31-1.27-.69-6.27-.69-6.27s-7.41-2.59-8.2-2.3,1.86-.8,2-1.75.23-1.43.23-1.43,3.12,0,5.53.25,3.41,1.47,3.41,2.35l.64,2.58.36,4.29-3,2"/><path class="cls-02-9" d="M133,29.24s-7.5,13.21-6.5,28.88a245.78,245.78,0,0,0,3,26.43s0,2,4,2a14,14,0,0,0,7-2,3.4,3.4,0,0,0,1-2c0-1-11-18.6-8-48.94C133.5,33.64,134.5,27.77,133,29.24Z"/><path class="cls-02-10" d="M130.5,46.5a94.41,94.41,0,0,0,2,18,145.29,145.29,0,0,0,6,19l.52,1.8s2.48-.8,2.48-2.8c0,0-6.45-14.41-7.23-22.71S132.5,39.5,133.5,33.5c0,0-.08-3.33.21-2.91S130,38,130.5,46.5Z"/><path class="cls-02-11" d="M133,29.24s-7.5,13.21-6.5,28.88a245.78,245.78,0,0,0,3,26.43s0,2,4,2a14,14,0,0,0,7-2,3.4,3.4,0,0,0,1-2c0-1-11-18.6-8-48.94C133.5,33.64,134.5,27.77,133,29.24Z"/><path class="cls-02-7" d="M126,46s-9,1-9,6,10,5,13,5,12-1,12-6-8-5-8-5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-03-2,.cls-03-3,.cls-03-8{fill:#fff;}.cls-03-2,.cls-03-3,.cls-03-7{fill-opacity:0.2;}.cls-03-2,.cls-03-4,.cls-03-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-03-2{stroke-width:0.75px;}.cls-03-4{fill:none;}.cls-03-5{fill:#ec1c24;}.cls-03-6{fill-opacity:0.1;}.cls-03-8{fill-opacity:0.4;}""",
|
||||
"""<g id="accessory-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M136.5,83.5c-3.39.31-11,2-13,4a4.38,4.38,0,0,0-1,3v5a18.26,18.26,0,0,0,13,3c8-1,16-6,16-6s0-6-1-7-1-2-5-2S138.74,83.3,136.5,83.5Z"/><path class="cls-03-2" d="M125.7,86.16s-2.2,1.34-1.2,3.34,7,3,12,2a53.37,53.37,0,0,0,11-4s3.57-2.09,1.29-3.54S131.89,82.83,125.7,86.16Z"/><path class="cls-03-3" d="M125.7,86.16s-2.2,1.34-1.2,3.34,7,3,12,2A55.23,55.23,0,0,0,145.11,89c-1.11-2-8.42-5.89-8.42-5.89S129.47,84.13,125.7,86.16Z"/><path class="cls-03-4" d="M136.5,83.5c-3.39.31-11,2-13,4a4.38,4.38,0,0,0-1,3v5a18.26,18.26,0,0,0,13,3c8-1,16-6,16-6s0-6-1-7-1-2-5-2S138.74,83.3,136.5,83.5Z"/><path class="cls-03-5" d="M134.5,73.5s-5,1-5,6,1,7,1,7a18.58,18.58,0,0,0,8,0c4-1,5-3,5-3S142.5,73.5,134.5,73.5Z"/><path class="cls-03-6" d="M139.5,75.5s2,3,1,6-6,5-6,5,8-1,9-3S140.5,76.5,139.5,75.5Z"/><path class="cls-03-4" d="M134.5,73.5s-5,1-5,6,1,7,1,7a18.58,18.58,0,0,0,8,0c4-1,5-3,5-3S142.5,73.5,134.5,73.5Z"/><path class="cls-03-7" d="M144.5,88.5c1,1,0,7.3,0,7.3l7-3.3s0-6.14-1.5-7.57C148,88,147,88,144.5,88.5Z"/><path class="cls-03-8" d="M133.5,75.5a1,1,0,0,1,1,1c0,1,0,6-2,6s-3,0-2-4S133.5,75.5,133.5,75.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-04-2,.cls-04-3{fill:none;stroke-linecap:round;stroke-linejoin:round;}.cls-04-2,.cls-04-3,.cls-04-5{stroke:#000;}.cls-04-3,.cls-04-5{stroke-width:0.75px;}.cls-04-4,.cls-04-6{fill:#fff;}.cls-04-4{fill-opacity:0.4;}.cls-04-5{fill:#ec1c24;stroke-miterlimit:10;}.cls-04-6,.cls-04-7{fill-opacity:0.2;}""",
|
||||
"""<g id="accessory_05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M139.5,51.5s-24-2-32,33c0,0-6,25,20,25s32-17,34-32S149.5,51.5,139.5,51.5Z"/><path class="cls-04-2" d="M139.5,51.5c-15.88.08-26.77,10.62-32,33,0,0-5,26,20,25s34-18,34-32C161.5,60.5,149.5,51.5,139.5,51.5Z"/><path class="cls-04-3" d="M143.5,53.5s14,7,10,29-26,25-33,24-18-7-10.34-30.84C114.5,59.5,130.5,47.5,143.5,53.5Z"/><path class="cls-04-4" d="M121.5,63.5c-3,1.87-8,6-9,11s-1,14,0,16,4,1,4-2,0-17,5-21S123.4,62.32,121.5,63.5Z"/><path class="cls-04-4" d="M128.5,57.5s-6,4-3,5,6-3,6-3S133.5,56.5,128.5,57.5Z"/><path class="cls-04-5" d="M125.5,71.5s-7-2-10,1c-1.88,2.07,0,8,5,10s8-1,9-4S127.5,71.5,125.5,71.5Z"/><polygon class="cls-04-6" points="131 79 135 81 134.62 78.67 130.38 76.63 130.06 78.6 131 79"/><path class="cls-04-3" d="M130.09,76.5l4.46,2s.89,2,0,3a4.07,4.07,0,0,1-2.67,1L129,80.79A5.22,5.22,0,0,0,130.09,76.5Z"/><ellipse class="cls-04-4" cx="120" cy="75.5" rx="2.5" ry="2"/><path class="cls-04-7" d="M123.5,71.5s5,2,4,6-6.49,4.82-8.25,4.41,7.54,3.33,9.89-2.54S127,72,123.5,71.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-05-2{fill:#fff;fill-opacity:0.4;}.cls-05-2,.cls-05-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-05-3{fill:none;stroke-width:0.75px;}""",
|
||||
"""<g id="accessory-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M125.5,157.5s8-4,15-3,20,8,26,5l8-4s-3,13-14,14-29-3-35-2-25,7-30,6-10-1-12-11c0,0-1-3,3,0s11,4,18-1S119.5,153.5,125.5,157.5Z"/><path class="cls-05-2" d="M125.5,157.5s8-4,15-3,20,8,26,5l8-4s-3,13-14,14-29-3-35-2-25,7-30,6-10-1-12-11c0,0-1-3,3,0s11,4,18-1S119.5,153.5,125.5,157.5Z"/><ellipse cx="107.5" cy="165.25" rx="1" ry="1.25"/><ellipse cx="99.5" cy="168.25" rx="1" ry="1.25"/><ellipse cx="91.75" cy="168.94" rx="0.75" ry="0.94"/><ellipse cx="115.5" cy="163.25" rx="1" ry="1.25"/><ellipse cx="134.5" cy="162.25" rx="1" ry="1.25"/><ellipse cx="145.5" cy="163.25" rx="1" ry="1.25"/><ellipse cx="154.5" cy="164.25" rx="1" ry="1.25"/><ellipse cx="161.5" cy="163.25" rx="1" ry="1.25"/><circle class="cls-05-3" cx="125" cy="162" r="2.5"/><circle cx="125" cy="162" r="0.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-06-2,.cls-06-4{fill-opacity:0.2;}.cls-06-3,.cls-06-6{fill:none;}.cls-06-3,.cls-06-5,.cls-06-6{stroke:#000;}.cls-06-3,.cls-06-5{stroke-miterlimit:10;}.cls-06-4{fill:#fff;}.cls-06-5{fill:#ec1c24;stroke-width:0.75px;}.cls-06-6{stroke-linecap:round;stroke-linejoin:round;}""",
|
||||
"""<g id="accessory-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M131,59s-13,1-14,6a2.62,2.62,0,0,0,.5,2.5c1,1,3,5,4,8s2,5,0,5-8,2-10,4-1,5-1,5l1,9a33.15,33.15,0,0,0,18,5c16.5.5,34-9,35-10s-1-11-1-11c.16-.17-2.84-5.17-4-5-1,.14-12,0-12,0s0-15-1-16S141.5,58.5,131,59Z"/><path class="cls-06-2" d="M151.5,88.5s1.14,9.54.57,10.77c0,0,12.43-4.77,12.43-5.77s0-9-1-11a27.36,27.36,0,0,0-4-5s1.59,1.44-.21,3.72-5.36,6.31-8.08,6.3Z"/><path class="cls-06-3" d="M131,59s-13,1-14,6a2.62,2.62,0,0,0,.5,2.5c1,1,3,5,4,8s2,5,0,5-8,2-10,4-1,5-1,5l1,9s6,5,18,5c18,0,34-9,35-10s-1-11-1-11-3-4.79-4-5-12-.18-12,0c0,0,0-15-1-16S141.5,58.5,131,59Z"/><path class="cls-06-4" d="M121.5,80.5s-7.3,1.22-8.65,3.11.21,5.68,3.93,6.29,12.31,2.13,27-.63l7.42-1.75-10.71-4A38.39,38.39,0,0,0,121.5,80.5Z"/><path class="cls-06-3" d="M132.5,67.5s15-1,14-5-14-3.56-14-3.56S118,60,117.26,64.25,132.5,67.5,132.5,67.5Z"/><path class="cls-06-5" d="M121.5,75.5s0,2,6,3,13-2,16-4,3.33-3.41,3.33-3.41l.67,6.41a10.67,10.67,0,0,1-8,6c-13,3-16.91-3.58-16.91-3.58Z"/><path class="cls-06-2" d="M137,67l3.87,16.18S147,80,147,77s0-13.73,0-13.73S145.09,67,137,67Z"/><path class="cls-06-4" d="M136.18,59l.82,8s-18,2-20-2S130.36,58,136.18,59Z"/><path class="cls-06-6" d="M113.16,83.3s-6.66,9.2,23.18,7c25.17-1.75,23.8-12.39,23.8-12.39l3.36,4.64s2,10,1,11-18,10-33.68,10a45.47,45.47,0,0,1-16.46-3.35,7.75,7.75,0,0,1-2.86-1.61l-1.2-10A5.34,5.34,0,0,1,113.16,83.3Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-07-10,.cls-07-2,.cls-07-4,.cls-07-8,.cls-07-9{fill:none;}.cls-07-10,.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke:#000;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8{stroke-linecap:round;}.cls-07-2,.cls-07-3,.cls-07-4,.cls-07-5,.cls-07-6,.cls-07-8,.cls-07-9{stroke-linejoin:round;}.cls-07-3,.cls-07-5{fill:#fff;}.cls-07-3,.cls-07-5,.cls-07-6,.cls-07-7{fill-opacity:0.2;}.cls-07-3,.cls-07-4{stroke-width:0.75px;}.cls-07-10,.cls-07-8,.cls-07-9{stroke-width:1.5px;}.cls-07-10{stroke-miterlimit:10;}""",
|
||||
"""<g id="accessory-08"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5s-1-1-6-1S131.5,84.5,128.5,86.5Z"/><path class="cls-fill-1" d="M173.5,21.5l-35,17s-.57,2.41.22,3.71l.78,1.29,37-18S178.5,20.5,173.5,21.5Z"/><path class="cls-fill-1" d="M96.5,58.5s-2,2-1,3,3,0,3,0l33.66-15.9a6.44,6.44,0,0,1-.66-3.1Z"/><path class="cls-fill-1" d="M134.5,47.5l-1,37a4.33,4.33,0,0,0,4,2,7.65,7.65,0,0,0,5-2l-2-40S135.5,44.5,134.5,47.5Z"/><path class="cls-fill-1" d="M136.5,38.5l-4,2a3.76,3.76,0,0,0-.81,1.55,5.34,5.34,0,0,0-.19,1.45c0,2,2,4,3,4,0,0-.17-.54,1.42-1.77a8.26,8.26,0,0,1,4.32-1.22h.26s-2-2-2-3v-3Z"/></g><path class="cls-07-2" d="M134.5,47.5l-1,37a4.33,4.33,0,0,0,4,2,7.65,7.65,0,0,0,5-2l-2-40S135.5,44.5,134.5,47.5Z"/><path class="cls-07-3" d="M128.5,87.5s0,2,6,2,11-1,13-3c.48-.55.5-1.75-2.25-1.87-1,0-3.15,0-3.15,0a6.77,6.77,0,0,1-4.1,1.8c-3.5.1-4.31-1.6-4.31-1.6S128.5,85.5,128.5,87.5Z"/><path class="cls-07-2" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5s-.8-1-5.8-1a7.15,7.15,0,0,1-4.57,2c-1.63,0-4.17,0-4.63-1.72A10.71,10.71,0,0,0,128.5,86.5Z"/><path class="cls-07-4" d="M128.5,86.5a3.49,3.49,0,0,0-1,2v3s3,3,9,3,12-4,12-4v-5c-.63-.65-1.9-1.15-6-1a6.16,6.16,0,0,1-4.17,1.8c-3.83.2-4.41-1.46-4.41-1.46S129.5,85.5,128.5,86.5Z"/><path class="cls-07-2" d="M136.5,38.5l-4,2a3.76,3.76,0,0,0-.81,1.55,5.34,5.34,0,0,0-.19,1.45c0,2,2,4,3,4,0,0-.17-.54,1.42-1.77a8.26,8.26,0,0,1,4.32-1.22h.26s-2-2-2-3v-3Z"/><path class="cls-07-5" d="M96.5,58.5s-2,2-1,3,3,0,3,0l33.8-15.6a7.45,7.45,0,0,1-.8-3.4Z"/><path class="cls-07-5" d="M173.5,21.5l-35,17s-.57,2.41.22,3.71l.78,1.29,37-18C177.52,25.52,179.49,20.53,173.5,21.5Z"/><circle class="cls-07-6" cx="175.5" cy="23.5" r="2"/><path d="M137.5,5.5s-2,0,1,2,32,15,32,15l2-1-33-15Z"/><path d="M56.72,44a1.7,1.7,0,0,0,1.2,1.19L96.29,58.83l1.89-1.2L58.81,44.1S56.44,43,56.72,44Z"/><path d="M114.5,17.5s-2,0,1,2,32,15,32,15l2-1-33-15Z"/><path d="M102,26.53s-2,.12,1.12,1.94S133,40.2,133,40.2l1.91-.77-30.83-12Z"/><path d="M177.5,24.19,212,41c1,1,.39,1.37-2,1L176.5,25.5S177,25.38,177.5,24.19Z"/><path d="M154.91,35.41l35.6,14.34S192,51,188.58,50.88L154,36.79Z"/><polygon points="102.89 59.02 134.34 68.69 133.65 69.98 101 59.98 102.89 59.02"/><path d="M142,42.54l35.55,13c2.4.9.47,2.47-2,1.19L140.2,43.79Z"/><path class="cls-07-7" d="M136,38.75a5.37,5.37,0,0,0-.5,2.75c0,2,2.22,3.37,2.22,3.37l1.8,40.84L144,88.34v4l4.46-1.89v-5a35.36,35.36,0,0,0-6-1l-2-40a3.7,3.7,0,0,1-2-3v-3Z"/><path class="cls-07-8" d="M119.5,47.5l-32-12s-3-1-4,1,0,3,2,4,27,10,27,10"/><line class="cls-07-8" x1="116.05" y1="49.09" x2="83.95" y2="37.51"/><line class="cls-07-9" x1="125.5" y1="49.2" x2="134.36" y2="52.57"/><line class="cls-07-10" x1="117.9" y1="52.66" x2="134" y2="59"/><line class="cls-07-10" x1="121.42" y1="50.93" x2="134.27" y2="55.83"/><path class="cls-07-10" d="M141,54.57,158,62s4,2,3,4-5,1-6,1-13.65-5.5-13.65-5.5"/><line class="cls-07-9" x1="141.18" y1="58.12" x2="160.5" y2="66.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-08-2{fill:none;}.cls-08-2,.cls-08-3,.cls-08-5,.cls-08-6,.cls-08-7{stroke:#000;stroke-miterlimit:10;}.cls-08-3,.cls-08-4{fill:#fff;}.cls-08-3,.cls-08-4,.cls-08-8{fill-opacity:0.2;}.cls-08-5{fill:#716558;}.cls-08-6{fill:#9a8479;}.cls-08-7{fill:#c1b49a;}""",
|
||||
"""<g id="accessory-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M135,83s-13,2-13,5a54.33,54.33,0,0,0,.5,6.5s4,4,13,3a37.83,37.83,0,0,0,16-6v-5l-3-4S143.5,81.5,135,83Z"/><path class="cls-08-2" d="M135,83s-13,2-13,5a54.33,54.33,0,0,0,.5,6.5s4,4,13,3a37.83,37.83,0,0,0,16-6v-5l-3-4S143.5,81.5,135,83Z"/><path class="cls-08-3" d="M123.5,88.5s2,3,10,2,14-3,15-5,1.19-2.37-1.41-3.18-13.22.86-16.41,1.52S122.5,85.5,123.5,88.5Z"/><path class="cls-08-4" d="M123.5,88.5s2,3,10,2a47.41,47.41,0,0,0,11.86-2.78c-.38.16-4.11-2.4-4.11-2.4s1.43-3.14,2-3.18a105.65,105.65,0,0,0-12.56,1.69C127.5,84.5,122.5,85.5,123.5,88.5Z"/><path class="cls-08-5" d="M139.5,66.5s-4-16,3-27c0,0-2-7-6-3,0,0-4,3-5,15a66,66,0,0,0,2,22S138.5,73.5,139.5,66.5Z"/><path class="cls-08-6" d="M119.5,36.5s1,13,3,19,4,11,6,16,5,7,6,5,0-3-2-10-5-18-5-25v-7a10.34,10.34,0,0,0-4-1C121.5,33.5,119.5,34.5,119.5,36.5Z"/><path class="cls-08-5" d="M110.5,50.5s8,8,10,13,5,13,6,10-3-18-6-22a61.22,61.22,0,0,0-5-6S110.5,45.5,110.5,50.5Z"/><path class="cls-08-7" d="M136.5,86.5s-5-19-19-28-20-7-20-7-4,3,1,6,12,5,18,10,12,14,12,16S131.5,88.5,136.5,86.5Z"/><path class="cls-08-7" d="M134.5,73.5s6-25,16-32,9,2,9,2v4s-4-2-8,5-9,19-9,23v8s-4,5-6,3c0,0-3.24-7.59-3.12-8.3S134.5,73.5,134.5,73.5Z"/><path class="cls-08-8" d="M144.36,88.12v7.11l7.14-3.73.07-4.64L150,84A11.59,11.59,0,0,1,144.36,88.12Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-09-2{fill:none;}.cls-09-2,.cls-09-4{stroke:#000;stroke-miterlimit:10;}.cls-09-3,.cls-09-4{fill:#fff;}.cls-09-3{fill-opacity:0.2;}.cls-09-5{fill:#d0d2d3;}.cls-09-6{fill-opacity:0.4;}""",
|
||||
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M122.5,88.5v6s3,4,13,3c12.5-.5,16-5,16-5v-6l-2-3a63.26,63.26,0,0,0-15,0C126.5,84.5,122.5,85.5,122.5,88.5Z"/><path class="cls-09-2" d="M122.5,88.5v6s2,3,13,3,16-5,16-5v-6l-2-3a63.26,63.26,0,0,0-15,0C126.5,84.5,122.5,85.5,122.5,88.5Z"/><path class="cls-09-3" d="M142.5,89.5c.2-.05,1.28-.44,1.5-.5-.63-1.83-4.53-5.64-5.5-5.7-7-.41-11.66,1-13.22,2-1.78,1.16-3.78,2.16,1.22,4.16C129.5,90.5,138.5,90.5,142.5,89.5Z"/><path class="cls-09-2" d="M142.5,89.5c4-1,10.83-4.83,3.92-5.92s-19.36.59-21.14,1.75-3.78,2.16,1.22,4.16C129.5,90.5,138.5,90.5,142.5,89.5Z"/><path class="cls-09-4" d="M130.5,52.5,128.24,84s1.26,3.47,7.26,2.47,7.33-3.44,7.33-3.44L132.5,54.5S131.5,51.5,130.5,52.5Z"/><path class="cls-09-5" d="M131.5,55.5l7.06,30.26s3.94-.26,3.94-2.26-10-29-10-29S130.5,50.5,131.5,55.5Z"/><path class="cls-09-2" d="M130.5,52.5,128.24,84s1.26,3.47,7.26,2.47,7.33-3.44,7.33-3.44L132.5,54.5S131.5,51.5,130.5,52.5Z"/><path class="cls-09-6" d="M144.09,88.72v8l7.41-4.26v-6l-2-3C150,85.88,147.82,87.51,144.09,88.72Z"/>"""
|
||||
)
|
||||
)
|
||||
|
||||
private val bodies: List<Part> = listOf(
|
||||
Part(
|
||||
""".cls-10-2{fill:#fff;fill-opacity:0.4;}.cls-10-3,.cls-10-5,.cls-10-7{fill:none;}.cls-10-3,.cls-10-4,.cls-10-5,.cls-10-6,.cls-10-7,.cls-10-8{stroke:#000;stroke-miterlimit:10;}.cls-10-3{stroke-width:1.2px;}.cls-10-4,.cls-10-6,.cls-10-9{fill-opacity:0.2;}.cls-10-5{stroke-width:1.25px;}.cls-10-6{stroke-width:0.75px;}.cls-10-8{fill:#6d6e70;}""",
|
||||
"""<g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-fill-1" d="M226.5,236.5s-6-10-26-12-56-2-81,13-43,44-43,65h115s3-31,22-53c0,0,8.88-7.45,14.44-9.22C227.94,240.28,228.5,239.5,226.5,236.5Z"/><path class="cls-fill-1" d="M286.5,302.5s4-50-16-59-44-8-57,6-22,43-22,53Z"/></g><path class="cls-10-2" d="M91.18,264s5.32.48,3.32,9.48-5,7-7,17-2.5,12-2.5,12H76.5S75.85,285.54,91.18,264Z"/><path class="cls-10-3" d="M286.5,302.5s4-50-16-59-44-8-57,6-22,43-22,53Z"/><path class="cls-10-4" d="M268.5,247.5s-8,8-11,26-2.25,29-2.25,29H286.5s3.84-41.79-12.58-56.9C273.92,245.6,271.5,244.5,268.5,247.5Z"/><path class="cls-10-5" d="M226.5,236.5s-6-10-26-12-56-2-81,13-43,44-43,65h115s3-31,22-53c0,0,8.88-7.45,14.44-9.22C227.94,240.28,228.5,239.5,226.5,236.5Z"/><path class="cls-10-6" d="M218.22,229.55s-18.72.95-31.72,20.95-17,37-17.5,52h22.5c-1.42.12,8.08-36.84,16-45.5,10-15.5,21-16.5,21-16.5S228.93,235.6,218.22,229.55Z"/><path class="cls-10-7" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-10-2" d="M80.61,255.48s2.89,2-6.11,12a94,94,0,0,0-17,26c-3,7-1.75,9-1.75,9H49.5S50.73,270.47,80.61,255.48Z"/><path class="cls-10-3" d="M106,247.52s-34.47,3-50.47,32c0,0-5,9-6,23h27S77.43,272.54,106,247.52Z"/><path class="cls-10-7" d="M153.5,251.5c7.6-.12,26-2,27-14s-27-8-27-8-22,1-22,11S147.35,251.6,153.5,251.5Z"/><path class="cls-10-8" d="M138.5,166.5s12,33,4,72c0,0,1,6,12,5s12-5,12-5,6-37-10-75C156.5,163.5,145.5,166.5,138.5,166.5Z"/><path class="cls-10-7" d="M161,176a15.59,15.59,0,0,1-7.53,3.51c-5,1-9,1-11.52,0"/><path class="cls-10-7" d="M163.66,185.38c-1.18,2.51-2.36,7.71-20,5.42"/><path class="cls-10-7" d="M166.32,200.74s-4.63,7.77-21.23,3.27"/><path class="cls-10-7" d="M167.42,213.41s-3.73,9.13-22.33,4.11"/><path class="cls-10-7" d="M167.42,227.17s-7.75,9.36-23.34,1.85"/><circle cx="154.5" cy="247.5" r="1.5"/><circle cx="166.5" cy="244.5" r="1.5"/><circle cx="174.5" cy="237.5" r="1.5"/><circle cx="168.5" cy="232.5" r="1.5"/><circle cx="142.5" cy="245.5" r="1.5"/><circle cx="137.5" cy="240.5" r="1.5"/><path class="cls-10-9" d="M150.5,164.5s10.5,29.5,11,45a328.75,328.75,0,0,1-1,33l6-3c3.29-1.87.5-61.5-10-76Z"/>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-11-2{fill:#fff;}.cls-11-2,.cls-11-6{fill-opacity:0.2;}.cls-11-3{fill:none;}.cls-11-3,.cls-11-4{stroke:#000;stroke-miterlimit:10;}.cls-11-4{fill:#6d6e70;}.cls-11-5{opacity:0.2;}""",
|
||||
"""<g id="body-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M160.5,246.5s-14-3-27,13-22,45-22,45h108s-20-35-25-40S176.5,247.5,160.5,246.5Z"/><path class="cls-11-2" d="M121.5,303.5s5-24,10-33,3.28-8.07,7.64-8.53,15.13-1.22,23.75,2.66,8.18,3.25,8.18,3.25l13.12-12.15s-12.68-9.66-26.18-9.44-23.5,12.22-26.5,15.22-14.17,23.45-19.09,40.22Z"/><path class="cls-11-3" d="M145.5,261.5s28,1,38,17,13,26,13,26h-86s13.77-34.15,18.39-38.57S144.5,261.5,145.5,261.5Z"/><path class="cls-11-2" d="M121.5,303.5s5-24,10-33,3.28-8.07,7.64-8.53c22.36-1.47,31.93,5.9,31.93,5.9l13.12-12.15s-12.68-9.66-26.18-9.44-23.5,12.22-26.5,15.22-14.17,23.45-19.09,40.22Z"/><path class="cls-11-3" d="M160.5,246.5s-14-3-27,13-22,45-22,45h108s-20-35-25-40S176.5,247.5,160.5,246.5Z"/><path class="cls-11-4" d="M149,192s1,62,2,64a11.16,11.16,0,0,0,9.06,3.21c5.44-.71,6.44-2.71,6.94-5.21,0,0-2.5-48.5-2.5-62.5Z"/><path class="cls-11-5" d="M159.5,191.5s3,36,3,40,1,27,1,27l3-1-2-66Z"/><path class="cls-11-3" d="M164.5,203.5a8.76,8.76,0,0,1-6,2c-4,0-8,0-9-1"/><path class="cls-11-3" d="M165.5,214.5a12.68,12.68,0,0,1-6,2c-3,0-7,.25-10-1.37"/><path class="cls-11-3" d="M165.8,225.5a10.11,10.11,0,0,1-6.3,2c-4,0-7.65-.2-9.82-2.1"/><path class="cls-11-3" d="M166,234.5s-.5,3-5.5,3a64.39,64.39,0,0,1-10.3-1"/><path class="cls-11-3" d="M166.5,245.5s-1,3-6,3a21.51,21.51,0,0,1-10-2"/><path class="cls-11-6" d="M195.58,301.91H218S203,275.78,198.73,270.14,186.5,256.5,184.5,255.5l-13,12s8,4.69,10.48,8.84S192.66,293.32,195.58,301.91Z"/><path class="cls-11-4" d="M193.5,272.5a6.85,6.85,0,0,0-2,8c2,5,8,6,8,6s7-6,15-2,9,7,9,15,14,3,14,3l2-2s4-21-19-31C220.5,269.5,209.5,265.5,193.5,272.5Z"/><path class="cls-11-6" d="M196.5,284.5a25.68,25.68,0,0,1,18-5c11,1,16,9,18,17s-4,10-4,10l-5-7s.05-13.88-10-15.44-14,2.44-14,2.44S195.5,286.5,196.5,284.5Z"/><path class="cls-11-3" d="M206,268.82a2.89,2.89,0,0,0-1.5,2.68c0,2,.1,8.87,7,11.94"/><path class="cls-11-3" d="M229.44,274.89s-3.94-1.39-5.94,1.61-3.62,9.25-2.81,12.13"/><path class="cls-11-3" d="M223.56,300.34c-.06.16.94-2.84,5.94-2.84a21.66,21.66,0,0,1,10.09,2.37"/><path class="cls-11-4" d="M127.07,268.36S108.5,263.5,98.5,279.5c0,0-5,9,1,20s7,4,7,4l7-5.25s-6-7.75-2-13.75c0,0,2.75-4.25,8.88-3.12Z"/><path class="cls-11-3" d="M119.5,281.5s2-7,1-10a5.45,5.45,0,0,0-3.56-3.62"/><path class="cls-11-3" d="M111.5,284.5a14.54,14.54,0,0,0-8-5c-5-1-5.44.94-5.44.94"/><path class="cls-11-3" d="M111,293.56a10.89,10.89,0,0,0-7.48.94c-4,2-4,5-4,5"/><path class="cls-11-6" d="M123.53,274.89s-7-2.39-13,.61-9,7-9,12,5,12,7,14,4.83-3.86,4.83-3.86-3.83-5.14-2.83-11.14c0,0,1-6,10-5Z"/><circle cx="145" cy="266" r="1.5"/><circle cx="160.5" cy="268.5" r="1.5"/><circle cx="173.5" cy="274.5" r="1.5"/><circle cx="182.5" cy="285.5" r="1.5"/><circle cx="188.5" cy="296.5" r="1.5"/><circle cx="133.5" cy="266.5" r="1.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-12-2{fill:#fff;fill-opacity:0.4;}.cls-12-3,.cls-12-7{fill:none;}.cls-12-3,.cls-12-5,.cls-12-6,.cls-12-7{stroke:#000;}.cls-12-3,.cls-12-5,.cls-12-6{stroke-miterlimit:10;}.cls-12-4,.cls-12-6{fill-opacity:0.2;}.cls-12-5{fill:#6d6e70;}.cls-12-7{stroke-linecap:round;stroke-linejoin:round;}""",
|
||||
"""<g id="body-03"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M180.45,252.21s-17.95-2.71-29.95,2.29-15,8-16,24c0,0,1,27,1,28s49,1,49,1v-9a27.15,27.15,0,0,1-3-13c0-8,7-6.16,7-6.16S177,279,177.24,263.25A16.2,16.2,0,0,1,180.45,252.21Z"/><path class="cls-fill-1" d="M133.5,277.5s-6.92-1.5-8-5.25l-.68-1.62c-.37-6.66,1.07-12,5.72-15,0,0,5.17-4.37,11.55-4.24,0,0,5.37.13,7.37,3.13,0,0-9,3.6-12,9.3s-3.09,13.32-3,14Z"/><path class="cls-fill-1" d="M188.5,247.5s-8,2-10,8-3,19,8,23,19-7,19-17S201.5,245.5,188.5,247.5Z"/></g><path class="cls-12-2" d="M134.5,254.5s3-1,3,0-6,5-7,10,0,8-2,8-4.67.09-2.84-10.45A13,13,0,0,1,134.5,254.5Z"/><path class="cls-12-3" d="M133.5,277.5s-6.92-1.5-8-5.25l-.68-1.62c-.37-6.66,1.07-12,5.72-15,0,0,5.17-4.37,11.55-4.24,0,0,5.37.13,7.37,3.13,0,0-9,3.6-12,9.3s-3.09,13.32-3,14Z"/><path class="cls-12-4" d="M189.5,248.5s9,0,9,10-4,16-9,17-7,.77-7,.77a12.77,12.77,0,0,0,8,3.07c5,.16,15-4.84,15-17.84s-7.26-14.64-14.13-14.32S188.5,248.5,189.5,248.5Z"/><path class="cls-12-3" d="M188.5,247.5s-8,2-10,8-3,19,8,23,19-7,19-17S201.5,245.5,188.5,247.5Z"/><path class="cls-12-5" d="M201.86,273.17s9.64,10.33,9.64,28.33h-13s2.74-14.31-7.13-22.15A11.59,11.59,0,0,0,201.86,273.17Z"/><path class="cls-12-4" d="M197.5,279.5s5,8,6,15,0,9,0,9h8S213,293,206.76,280.74c0,0-3.26-6.24-5.26-7.24a22,22,0,0,1-4.74,4.65Z"/><path class="cls-12-3" d="M180.45,252.21s-17.95-2.71-29.95,2.29-15,8-16,24c0,0,1,27,1,28s49,1,49,1v-9a27.15,27.15,0,0,1-3-13c0-8,7-6.16,7-6.16S177,279,177.24,263.25A16.2,16.2,0,0,1,180.45,252.21Z"/><path class="cls-12-6" d="M169.5,304.5s-7-21,0-42c0,0,3.39-7.79,10.2-10.39l.8.39s-3.64,3.11-3.32,10.06,2.16,14.4,9.74,16.17a5.19,5.19,0,0,0-5,3.77c-1.44,4,.38,11.28,1.47,13.64A13.05,13.05,0,0,1,184.5,301Z"/><path class="cls-12-2" d="M142.5,259.5a2.19,2.19,0,0,1,3,1q1.5,3-3,9c-3,4-3,14-2,19s2,10,2,13h-7.16S134,284,134.75,275.26s2.49-12.37,6.12-15.56Z"/><path class="cls-12-3" d="M150,254.71s-4.5-4.21-10.5-3.21-14,6-15,15,9,12,10,12C134.5,278.5,132.5,259.92,150,254.71Z"/><path class="cls-12-5" d="M130.17,276.81a64.62,64.62,0,0,1-1.51,10.87c-1.2,5.22-3.35,11-7.16,13.82l13.79-.59s-.79-21.41-.79-22.41Z"/><path class="cls-12-3" d="M134.5,290.5s-2.86-2.88-5.93-2.44"/><path class="cls-12-3" d="M124,299s5.55-1.48,9,2"/><path class="cls-12-5" d="M150,192s1,61,2,64c0,0,2,5,10,3,0,0,6-1,6-7,0,0-3.5-40.5-2.5-60.5C165.5,191.5,152.5,191.5,150,192Z"/><path class="cls-12-7" d="M164.5,202.5s0,3-5,3-8.56-1-9.28-1.5"/><path class="cls-12-7" d="M165,214.5s-1,2-5.18,2a39.35,39.35,0,0,1-9.38-1.58"/><path class="cls-12-3" d="M166,224.5s0,2-4.62,3-10.38-1-10.38-1"/><path class="cls-12-3" d="M166,233.5s0,2-3.21,3a16.53,16.53,0,0,1-11.79-1"/><path class="cls-12-3" d="M167,244.5a7,7,0,0,1-5.17,4c-4.13,1-10.48-1.51-10.48-1.51"/><path class="cls-12-3" d="M207,281.6s-4-2.4-12,2.4"/><path class="cls-12-3" d="M211.42,296.16s-2.24-4.33-12.58,1.51"/><path class="cls-12-4" d="M159,191.5h6.5v10s1,20,.89,29.33A182.26,182.26,0,0,0,168,252.75h0c-.5,4.25-5.5,5.75-5.5,5.75v-2c0-2,1-15,1-23s-4-40-4-40Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-13-2{fill:none;}.cls-13-2,.cls-13-5{stroke:#000;stroke-miterlimit:10;}.cls-13-3{fill:#fff;fill-opacity:0.4;}.cls-13-4{fill-opacity:0.2;}.cls-13-5{fill:#6d6e70;}""",
|
||||
"""<g id="body-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148.46,242.82s-35,5.68-33,23.68c0,0-1,7,9,20s13,20,13,20l51-2s1-7,9-18,11-13,11-24-13.09-25.36-60-19.68"/><path class="cls-13-2" d="M148.46,242.82s-35,5.68-33,23.68c0,0-1,7,9,20s13,20,13,20l51-2s1-7,9-18,11-13,11-24-13.09-25.36-60-19.68"/><path class="cls-13-3" d="M131.83,247.83s-6.33,4.67-7.33,12.67c0,0-3,7,4,14s13,18,14,25-7.56,2.2-7.56,2.2-5.72-8.89-11.08-16-10.81-14.18-7.58-25.17C116.28,260.49,118.17,254.17,131.83,247.83Z"/><circle cx="155.5" cy="267.5" r="1"/><circle cx="144" cy="268" r="1"/><circle cx="132" cy="270" r="1"/><circle cx="122" cy="274" r="1"/><circle cx="170" cy="267" r="1"/><circle cx="183" cy="270" r="1"/><circle cx="194" cy="275" r="1"/><path class="cls-13-4" d="M193.33,245.17s-1.83,5.33,1.17,8.33,4,4,2,8a21.05,21.05,0,0,0-3.72,4c-1.28,2,.72,4-1.28,9s-15,28-15,28l12.74-.81s-.74-2.19,8.26-15.19,13-17.33,10-29.17C207.5,257.33,204.17,249.83,193.33,245.17Z"/><path class="cls-13-2" d="M116.5,272.83s-2-6.33,6-8.33,19-5,40-5,41,6,40,18a10,10,0,0,1-2.17,5"/><path class="cls-13-5" d="M166.5,205.5h-14a12.13,12.13,0,0,0-5,1l1,38s0,11,10,11,11-9,11-9-3-17-3-27Z"/><path class="cls-13-2" d="M166,212.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-2" d="M166,224.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-2" d="M167,238.5s-2.06,3-9.25,3-9.25-1.5-9.25-1.5"/><path class="cls-13-4" d="M159.17,205.5s-.67,9,1.33,15,4,15,3,21-1,12.74-3,13.87,8-.87,9-8.87a168.89,168.89,0,0,1-3-28.92V205.5Z"/><path class="cls-13-5" d="M197.5,286.5s12,3,14,13,16,4,16,4,4-1-2-15A31.19,31.19,0,0,0,207.19,271a29.16,29.16,0,0,1-5.19,9.56A32.56,32.56,0,0,0,197.5,286.5Z"/><path class="cls-13-2" d="M219.5,279.5s-11.86,4.53-11.93,12.76"/><path class="cls-13-2" d="M228.5,296.5a15.7,15.7,0,0,0-17,4"/><path class="cls-13-4" d="M224,304.64l4.82-4.14s-2.14-16.5-11-23-10.33-6-10.33-6l-3.27,6S222.47,286.77,224,304.64Z"/><path class="cls-13-5" d="M117.31,275.57S103.5,282.5,100.5,301.5s17,0,17,0,1.85-8.67,8.92-12.34C126.42,289.16,118.13,278.64,117.31,275.57Z"/><path class="cls-13-2" d="M109,283s7.48,11.61,11.58,11.55"/><path class="cls-13-2" d="M102,296s6.9,7.76,11.5,6.47"/><path class="cls-13-4" d="M119.5,283.5s-13,7-12,21,10.64-5.12,10.64-5.12,6.36-8.88,8.36-9.88l-5.5-7Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-14-2{fill:none;}.cls-14-2,.cls-14-4{stroke:#000;stroke-miterlimit:10;}.cls-14-3,.cls-14-6{fill-opacity:0.2;}.cls-14-4{fill:#6d6e70;}.cls-14-5,.cls-14-6{fill:#fff;}.cls-14-5{fill-opacity:0.4;}""",
|
||||
"""<g id="body-05"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M137.41,252.15s-.91-4.65-11.91-3.65-13,12-13,18,4,13,12,14c0,0,3,0,3-1,0,0-3.17-6.44.41-16.72C127.91,262.78,133.33,254.8,137.41,252.15Z"/><path class="cls-fill-1" d="M194.5,244.5s-19,1-19,17,11,21,17,21,20-2,19-20S194.5,244.5,194.5,244.5Z"/><path class="cls-fill-1" d="M181.5,247.5a39.32,39.32,0,0,0-24-4c-14,2-28,14-30,19s-3,9,0,17,3,20,1,22h68s.68-18.41-.66-19.2c0,0-21.34,1.2-20.34-19.8a13.88,13.88,0,0,1,6.67-13.88Z"/></g><path class="cls-14-2" d="M194.5,244.5s-19,1-19,17,11,21,17,21,20-2,19-20S194.5,244.5,194.5,244.5Z"/><path class="cls-14-3" d="M195.5,245.5s8,5,6,18-9.78,17.77-14.39,17.89c0,0,21.41,6.8,24.4-15.54C211.51,265.84,212.5,245.5,195.5,245.5Z"/><path class="cls-14-2" d="M181.5,247.5a39.32,39.32,0,0,0-24-4c-14,2-28,14-30,19s-3,9,0,17,3,20,1,22h68s.68-18.41-.66-19.2c0,0-21.34,1.2-20.34-19.8a13.88,13.88,0,0,1,6.67-13.88Z"/><path class="cls-14-4" d="M195.5,282.5s7,11,6,19h12s2.5-15-7.25-23.5C206.25,278,203.5,282.5,195.5,282.5Z"/><path class="cls-14-2" d="M211.55,285.39s.73,7.21-11.66,6.16"/><path class="cls-14-5" d="M136.5,260.5a3.1,3.1,0,0,0-2,1c-1,1-3,5,0,5s4-1,4-3S137.5,260.5,136.5,260.5Z"/><path class="cls-14-5" d="M133.5,270.5a4.33,4.33,0,0,0-2,4c0,3,1,6,2,12s.67,14-1.67,15,6.67,0,6.67,0,4-16,0-25C138.5,276.5,136.5,269.5,133.5,270.5Z"/><path class="cls-14-3" d="M161.5,253.5s9,2,11,10,2,28,2,28v12l22,1v-19a4.38,4.38,0,0,0-1-3s-22-1-20-21c0,0,0-9,7-13a35,35,0,0,0-14.06-5.07l.06,5.07A10.39,10.39,0,0,1,161.5,253.5Z"/><path class="cls-14-2" d="M141.34,249.41s3.16,11.09,16.16,11.09,19.07-10,19-15"/><path class="cls-14-2" d="M144.5,301.5s2-12,1-18l23-1s1,17,0,19"/><path class="cls-14-2" d="M137.41,252.15s-.91-4.65-11.91-3.65-13,12-13,18,4,13,12,14c0,0,3,0,3-1C123.29,273.33,124.5,260.5,137.41,252.15Z"/><path class="cls-14-6" d="M118.5,252.5s5-3,3,1-5,5-6,11,4,11,4,11,2,2-1,2-7.7-8-5.35-17A16.51,16.51,0,0,1,118.5,252.5Z"/><path class="cls-14-4" d="M119.36,278.79s-3.86,5.71-4.86,10.71,1,12,1,12h10s-1-7,1-12,2.48-4.68,2.48-4.68l-1.48-5.32s-1.27,1.4-4.63.7-3.87-1.4-3.87-1.4"/><path class="cls-14-2" d="M114.92,286.75s3.58,4.75,11.58,2.75"/><circle cx="158.5" cy="257.5" r="1"/><circle cx="150" cy="255" r="1"/><circle cx="146" cy="251" r="1"/><circle cx="168" cy="254" r="1"/><circle cx="172" cy="247" r="1"/><path class="cls-14-3" d="M125,280.39s-4.48,3.61-3.22,21.11H125s-1-11.5,4-16.5l-1.58-5.08Z"/><path class="cls-14-3" d="M202,281.28S209,293,209,302s4.79-1.37,4.79-1.37S215,286,206,278A6.19,6.19,0,0,1,202,281.28Z"/><path class="cls-14-4" d="M147,200s-1,42,2,50c0,0,12,9,20-2,0,0-3.5-26.5-3.5-48.5A77.11,77.11,0,0,0,147,200Z"/><path class="cls-14-2" d="M166,211.5s-1.08,3-8.68,3-10.48-1.37-10.48-1.37"/><path class="cls-14-2" d="M166,223.5s-1.08,3-8.68,3-10.48-1.37-10.48-1.37"/><path class="cls-14-2" d="M147.5,238.5a15.39,15.39,0,0,0,8,2c5,0,11.32-1.58,12.16-4.29"/><path class="cls-14-3" d="M158.67,199.17s.83,11.33,1.83,18.33,2,15,2,20,.33,13.93-1.33,16c0,0,4.33,0,7.33-6,0,0-3-26-3-33v-15Z"/><circle cx="148" cy="286" r="0.5"/><circle cx="165.5" cy="285.5" r="0.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-15-2{fill:#fff;}.cls-15-2,.cls-15-4{fill-opacity:0.2;}.cls-15-2,.cls-15-3,.cls-15-5{stroke:#000;stroke-miterlimit:10;}.cls-15-3{fill:none;}.cls-15-5{fill:#6d6e70;}""",
|
||||
"""<g id="body-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M145.5,301.5s-1-18-1-23c0,0,.37-3.41,15.68-2.2s10.15-.26,10.15-.26,5.17-.54,5.17,2.46,0,19,3,23Z"/><path class="cls-15-2" d="M159.5,286.5s16-1,16-8c0,0,4-3-14-2h-12s-5,0-5,2S143.5,285.5,159.5,286.5Z"/><path class="cls-15-3" d="M145.5,301.5s-1-18-1-23c0,0,.37-3.41,15.68-2.2s10.15-.26,10.15-.26,5.17-.54,5.17,2.46,0,19,3,23Z"/><path class="cls-15-4" d="M167.5,282.5l4.79.67.55,18.33h5.66s-2.64-3-2.82-15l-.18-8s-1-3-4-2.5v3.5A6.93,6.93,0,0,1,167.5,282.5Z"/><circle cx="164" cy="291" r="1.5"/><circle cx="155.5" cy="291.5" r="1.5"/><circle cx="148.5" cy="288.5" r="1.5"/><circle cx="171.5" cy="288.5" r="1.5"/><path class="cls-15-5" d="M146,200s3,68,2,79c0,0,2,6,12,5s11.5-4.5,11.5-4.5-3-75-2-80C169.5,199.5,151.5,199.5,146,200Z"/><path class="cls-15-4" d="M161.5,200.5s0,20,1,27,3,24,4,35a166.5,166.5,0,0,1,.61,20s4.39-2,4.39-3-1.38-41.67-1.69-48.33-.31-31.67-.31-31.67h-8Z"/><path class="cls-15-3" d="M146.43,210.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,222.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,234.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M147.43,246.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M148.43,259.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-3" d="M148.43,271.28a31.28,31.28,0,0,0,13.07,3.22c7,0,9.83-3.22,9.83-3.22"/><path class="cls-15-4" d="M144.5,281.5l2,21,33-1s-4-6-4-23c0,0-1,8-15,8S145.5,282.5,144.5,281.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-16-2,.cls-16-5{fill:none;}.cls-16-2,.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke:#000;}.cls-16-2{stroke-linecap:round;stroke-linejoin:round;}.cls-16-3,.cls-16-6,.cls-16-7{fill-opacity:0.2;}.cls-16-4,.cls-16-6{fill:#fff;}.cls-16-4{fill-opacity:0.4;}.cls-16-5,.cls-16-6,.cls-16-7,.cls-16-8,.cls-16-9{stroke-miterlimit:10;}.cls-16-8{fill:#f9ec31;}.cls-16-9{fill:#6d6e70;}""",
|
||||
"""<g id="body-07"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27,19-6,19-6S219.5,231.5,158.5,234.5Z"/><path class="cls-fill-1" d="M277.5,301.5c-.33-7.3-4.07-15.56-12-25,0,0-11.5-7.5-25.28-2.69-7.72,4.69-13.72,11.69-11.72,28.69Z"/><path class="cls-fill-1" d="M74.5,276.5a29.05,29.05,0,0,0-26,16c-9,17,10,17,10,17l8.79-9.5S64.5,293.5,74.5,276.5Z"/></g><path class="cls-16-2" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27,19-7,19-7S219.5,231.5,158.5,234.5Z"/><path class="cls-16-3" d="M215,245s6.5,1.46-.5,16.46-13,26-10,41,24.32,0,24.32,0-3.6-15.79,7-25.4c12.91-11.65,19.71-3.56,16.64-5.6C252.5,271.5,235.49,253.58,215,245Z"/><path class="cls-16-2" d="M158.5,234.5s-59,1-84,42,23,42,23,42l132-13s-5-16,5-27c7-9,19-7,19-7S219.5,231.5,158.5,234.5Z"/><path class="cls-16-4" d="M62.5,280.5s5,0,0,9-8,11-9,14-6.5-2.33-6.5-2.33-3.23-4.81,5.63-14.74C52.63,286.43,57.5,280.5,62.5,280.5Z"/><path class="cls-16-5" d="M74.5,276.5a29.05,29.05,0,0,0-26,16c-9,17,10,17,10,17l8.79-9.5S64.5,293.5,74.5,276.5Z"/><path class="cls-16-4" d="M77.5,301.5s0-19,13-30,16-5,15-2-14,24-14,32S77.5,301.5,77.5,301.5Z"/><path class="cls-16-5" d="M277.5,301.5s-5-39-37.28-27.69c-7.72,4.69-13.72,11.69-11.72,28.69Z"/><path class="cls-16-3" d="M261.5,304.5s4-7,3-15-8.36-16.57-8.36-16.57a25.86,25.86,0,0,1,18.77,16.29,27.17,27.17,0,0,1,2.58,15.29Z"/><path class="cls-16-6" d="M103.5,303.5s2-30,41-29,41,29,41,29H174.25c-.75-2-12.12-18.58-30.75-18-15.88-.37-26.93,4.64-30.5,18Z"/><path class="cls-16-7" d="M113.5,301.5s3-16,28-16,32,17,32,17Z"/><path class="cls-16-8" d="M118.5,300.5s9-12,24-11,24,12,24,12Z"/><circle cx="125.5" cy="283.5" r="1"/><circle cx="113" cy="291" r="1"/><circle cx="148" cy="280" r="1"/><circle cx="170" cy="289" r="1"/><path class="cls-16-9" d="M147,200s0,44,1,45a14.41,14.41,0,0,0,14,4c8-2,7-7,7-7a144.32,144.32,0,0,1-2.5-24.5v-19S148.5,198.5,147,200Z"/><path class="cls-16-3" d="M159.5,202.5s1,17,2,23,2,22.83-2,23.91c0,0,9.62-.9,9.31-8.41s-2.43-19.17-2.37-30.34.06-12.17.06-12.17l-7,.11Z"/><path class="cls-16-5" d="M147,233.5s1.08,3,5.4,3,12.83-1.33,15-4.66"/><path class="cls-16-5" d="M147.5,219.32s1,2.68,5,2.68,11.89-1.18,13.94-4.16"/><path class="cls-16-5" d="M147.5,208.07s1,1.93,5,1.93,11.89-.85,13.94-3"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-17-2{fill:#020202;}.cls-17-2,.cls-17-4,.cls-17-6,.cls-17-7{fill-opacity:0.4;}.cls-17-3{fill-opacity:0.2;}.cls-17-4,.cls-17-6{fill:#fff;}.cls-17-4,.cls-17-5,.cls-17-8{stroke:#000;stroke-miterlimit:10;}.cls-17-5{fill:none;}.cls-17-8{fill:#6d6e70;}""",
|
||||
"""<g id="body-08"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M125.5,286.5s-5,5-4,15l83,1s-2-19-13-22-16-1.95-16-1.95h-.39l.39,3.95s-4.64,7.17-15.82,7.08-13.57-2.76-15.87-5.92c0,0-1.3-4.45-.8-6.81C143,276.85,135.5,277.5,125.5,286.5Z"/><path class="cls-fill-1" d="M143.5,272.5a4.87,4.87,0,0,0-.19,1.41c0,2.53,1.69,7.59,15.19,7.59,18,0,16-9,16-9s-1-5-13-5S145.5,268.5,143.5,272.5Z"/><path class="cls-fill-1" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10s-.12-5-15.56-5-14.44,4-15.44,6a10.07,10.07,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/></g><path class="cls-17-2" d="M166,276l4,3s6-4,4-7-2.41-2.08-2.41-2.08L171,274Z"/><path class="cls-17-3" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10c-2.8,12.94-32.54,10.72-31,1a24,24,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/><path class="cls-17-4" d="M143.5,272.5a4.87,4.87,0,0,0-.19,1.41c0,2.53,1.69,7.59,15.19,7.59,18,0,16-9,16-9s-1-5-13-5S145.5,268.5,143.5,272.5Z"/><path class="cls-17-5" d="M143.5,282.5s0,6,14,7,18-7,18-7l-1-10s-.12-5-15.56-5-14.44,4-15.44,6a10.07,10.07,0,0,0-.43,4C143.14,280,143.5,282.5,143.5,282.5Z"/><path class="cls-17-5" d="M125.5,286.5s-5,5-4,15l83,1s-2-19-13-22-16-1.95-16-1.95h-.39l.39,3.95s-4.64,7.17-15.82,7.08-13.57-2.76-15.87-5.92c0,0-1.3-4.45-.8-6.81C143,276.85,135.5,277.5,125.5,286.5Z"/><path class="cls-17-6" d="M134.5,281.5s5-1,3,2-7,4-9,9a17.55,17.55,0,0,0-1.2,9.07l-5.8-.07S120,288.59,129.74,283A12.58,12.58,0,0,1,134.5,281.5Z"/><path class="cls-17-7" d="M168.21,288.58s15.29-5.08,20.29-1.08,5,12,5,14,11,0,11,0-2.7-17.32-10.85-20.16a50.64,50.64,0,0,0-18.15-2.84h0v4S169.92,288.67,168.21,288.58Z"/><path class="cls-17-7" d="M170.33,279.34v7.9s5.17-3.74,5.17-4.74-.94-9.36-.94-9.36S175.16,277.17,170.33,279.34Z"/><path class="cls-17-8" d="M143,201s8,21,7,38-2,35-2,35,.5,3.5,10.5,3.5,13-5,13-5,0-37-3-53-8-22-8-22S147.5,200.5,143,201Z"/><path class="cls-17-3" d="M153.89,199s6.61,15.53,8.61,28.53,4,24,4,33v15.63s5-1.63,5-3.63,1.23-37.53-5.39-62.77c0,0-3.61-10.23-5.61-12.23Z"/><path class="cls-17-5" d="M150,226.5s14.89,2.35,18.24-8.32"/><path class="cls-17-5" d="M150.5,237.5s1,4,8,3,11-4,12-6"/><path class="cls-17-5" d="M149.63,250.63s.88,3.88,6.88,3.88S170,251,171.24,248.74"/><path class="cls-17-5" d="M147.5,213.5s1,2,6,2,12.85-4.58,12.43-6.29"/><path class="cls-17-5" d="M149,262.5s0,5,8.18,4,12.77-3.62,14.06-6.31"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-fill-2,.cls-18-3,.cls-18-5,.cls-18-6{stroke:#000;stroke-miterlimit:10;}.cls-18-3{fill:none;}.cls-18-4{fill:#fff;}.cls-18-4,.cls-18-5{fill-opacity:0.4;}.cls-18-6{fill:#6d6e70;}.cls-18-7{opacity:0.2;}.cls-18-8{fill-opacity:0.2;}""",
|
||||
"""<g id="body-09"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M95.13,227.69S74.5,232.5,73.5,248.5c-.44,7.05,4.25,12.09,9.63,15.53a52.45,52.45,0,0,0,14.79,6.13s-3.16-18.66-2.79-30.66S95.13,227.69,95.13,227.69Z"/><path class="cls-fill-1" d="M97.5,301.5s2-21,0-35-2-29-2-36,14-26.11,46-28.05,59-1.95,63,18.05,3,30,3,30,2,53,0,55S97.5,301.5,97.5,301.5Z"/><path class="cls-fill-2" d="M204.5,220.5s28,1,29,23S216.6,267,208,268.24C208,268.24,209.5,233.5,204.5,220.5Z"/></g><path class="cls-18-3" d="M95.5,230.5s-7,20,40,23,69-15.69,69.51-30.35-12.33-22.39-48.92-21.52S100.5,212.5,95.5,230.5Z"/><path class="cls-18-4" d="M87.5,232.5s-9,9-9,14,6,16,2,15-7.57-12-5.79-18.48S86.5,229.5,87.5,232.5Z"/><path class="cls-18-3" d="M97.5,301.5s2-21,0-35-2-29-2-36,14-26.11,46-28.05,59-1.95,63,18.05,3,30,3,30,2,53,0,55S97.5,301.5,97.5,301.5Z"/><path class="cls-18-5" d="M102.5,227.5s-4,22,38,21c0,0,56,2,57-26s-69-14-69-14S106.5,214.5,102.5,227.5Z"/><path class="cls-18-3" d="M95.5,230.5s-7,20,40,23,69-15.69,69.51-30.35-12.33-22.39-48.92-21.52S100.5,212.5,95.5,230.5Z"/><path class="cls-18-3" d="M95.13,227.69S74.5,232.5,73.5,248.5c-.44,7.05,4.25,12.09,9.63,15.53a52.45,52.45,0,0,0,14.79,6.13s-3.16-18.66-2.79-30.66S95.13,227.69,95.13,227.69Z"/><path class="cls-18-3" d="M204.5,220.5s28,1,29,23S216.6,267,208,268.24C208,268.24,209.5,233.5,204.5,220.5Z"/><path class="cls-18-4" d="M104.5,263.5a4.45,4.45,0,0,0,2,5c3,2,7,0,6-2S106.5,260.5,104.5,263.5Z"/><path class="cls-18-4" d="M109.5,276.5s-5-1-5,9a161.75,161.75,0,0,0,1,18l11,1s-3-5-3-14S112.5,278.5,109.5,276.5Z"/><path class="cls-18-6" d="M224.9,263.3s-1.4,20.2,16.6,39.2h-22s-7.35-6.33-11.18-20.67l.18-13.33S218.3,268.1,224.9,263.3Z"/><path class="cls-18-3" d="M208.5,281.5a10.58,10.58,0,0,1,11-6c8,1,7.71,3,7.71,3"/><path class="cls-18-3" d="M215.68,298s4.82-4.52,10.82-4.52a18.1,18.1,0,0,1,9.41,2.26"/><path class="cls-18-7" d="M217.85,223.44s9.65,6.06,7.65,17.06-8,16.09-17,18.54v9.46s24.08-.06,25-22.53C233.54,246,236.21,233.39,217.85,223.44Z"/><path class="cls-18-6" d="M76.5,302.5s9-10,8-24l-1-14,14,6s2,24,0,31S76.5,302.5,76.5,302.5Z"/><path class="cls-18-3" d="M97.5,282.5s-3.87-3-12.94-1"/><path class="cls-18-3" d="M97.5,298.5s-2.26-5.48-16.63-2.74"/><path class="cls-18-8" d="M90.5,270.5s7,25,0,32l7-1v-31l-8.76-3.76Z"/><path class="cls-18-8" d="M85.5,273.5s5,22-2,29-4.6-3.27-4.6-3.27,4.73-4.71,5.66-15.22l.94-10.51"/><path class="cls-18-8" d="M225.8,272.89s-12.3-6.39-12.3.61,11,28,14,31-6,0-6,0-10.71-12.76-12.36-19.88-.64-16.12-.64-16.12l16.4-5.2Z"/><path class="cls-18-8" d="M197.51,304.5s4-34,0-53c-2.39-11.36-2.58-11.5-2.55-11.32,0,0,8.57-4.37,10.06-17s3.49,45.35,3.49,45.35v37Z"/><circle cx="104" cy="254" r="1.5"/><circle cx="115.5" cy="258.5" r="1.5"/><circle cx="129.5" cy="261.5" r="1.5"/><circle cx="146.5" cy="262.5" r="1.5"/><circle cx="164.5" cy="260.5" r="1.5"/><circle cx="180.5" cy="255.5" r="1.5"/><circle cx="193.5" cy="248.5" r="1.5"/><circle cx="202.5" cy="241.5" r="1.5"/><path class="cls-18-6" d="M139.5,181.5l1,39s0,7,10,7,11-10,11-10a155.16,155.16,0,0,1-3-16c-1-8,0-20,0-20h-19Z"/><path class="cls-18-3" d="M158,185.5s.33,3.92-18.35,2"/><path class="cls-18-3" d="M158,197.5s0,6-17.5,1"/><path class="cls-18-3" d="M160,210.5s-2.35,7-20,2"/><path class="cls-18-8" d="M151.5,183.5a14.82,14.82,0,0,0,0,10c2,5,5,18,4,24a61.38,61.38,0,0,1-2.35,9.74s9.35-4.74,8.35-9.74-2.37-8.51-3.18-17.76a104.59,104.59,0,0,1,.18-18.24h-6.4Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-19-2{fill-opacity:0.6;}.cls-19-11,.cls-19-13,.cls-19-14,.cls-19-2,.cls-19-3,.cls-19-4,.cls-19-6,.cls-19-8{stroke:#000;}.cls-19-11,.cls-19-13,.cls-19-2,.cls-19-4,.cls-19-6,.cls-19-8{stroke-miterlimit:10;}.cls-19-11,.cls-19-14,.cls-19-3,.cls-19-8{fill:none;}.cls-19-14,.cls-19-3{stroke-linecap:round;stroke-linejoin:round;}.cls-19-10,.cls-19-12,.cls-19-4,.cls-19-5{fill:#fff;}.cls-19-12,.cls-19-4{fill-opacity:0.2;}.cls-19-4{stroke-opacity:0;}.cls-19-5{fill-opacity:0.1;}.cls-19-6{fill:#6d6e70;}.cls-19-7{fill:#58595b;}.cls-19-13,.cls-19-9{fill-opacity:0.4;}.cls-19-10{fill-opacity:0.5;}.cls-19-11,.cls-19-14{stroke-width:0.75px;}""",
|
||||
"""<g id="body-10"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M222.5,301.5s-10-35,11-53,58-15,70,2,2,51,2,51Z"/><path class="cls-fill-1" d="M83.5,254.5s-6,28,67,26,77-30,76-36-12.3-10.12-26-13c0,5,1,22,1,22s-8,13-46,17c-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/><path class="cls-fill-1" d="M110.5,261.5s20,11,44,9,36-7,47-17l-1-24a10.53,10.53,0,0,1-.31,4.41c-.69,1.59-4.63,16.78-42.16,19.68s-47-4.1-50-16.1Z"/><rect class="cls-fill-1" x="83.5" y="250" width="143.5" height="58.5"/><path class="cls-fill-1" d="M87.5,247.5s-19-10-37-5-29,20-32,37c-1,11,1,16,3,20h64s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-fill-1" d="M108,237s-1,20,45,17,48.5-20.5,47.5-24.5c-.91-3.63-13.68-10.56-39-9.56,0,.56.26,3.3.26,3.3,1.74,9.26.74,15.26-9.26,16.26-3.41.33-10-5-10-5s-1-8-1-13C120.38,224.59,108.41,230,108,237Z"/></g><path class="cls-19-2" d="M108,237s-1,20,45,17,48.5-20.5,47.5-24.5c-.91-3.63-13.68-10.56-39-9.56,0,.56.26,3.3.26,3.3,1.74,9.26.74,15.26-9.26,16.26-3.41.33-10-5-10-5s-1-8-1-13C120.38,224.59,108.41,230,108,237Z"/><path class="cls-19-3" d="M110.5,261.5s20,11,44,9,36-7,47-17l-1-24a10.53,10.53,0,0,1-.31,4.41c-.69,1.59-4.63,16.78-42.16,19.68s-47-4.1-50-16.1Z"/><path class="cls-19-4" d="M193.37,260.15c-6.66,3.83-18.28,8.29-37.87,10.35-29,1-45-9-45-9s-1.5-16-2.5-23.13c-11.5,3.38-22.5,9.13-24.5,16.13,0,0-6,28,67,26,29.58-.81,47.84-5.89,59-12"/><path class="cls-19-5" d="M83.5,254.5s-6,28,67,26c29.58-.81,47.84-5.89,59-12-5.5-2.5-10.5-5.5-16.13-8.35-6.66,3.83-18.28,8.29-37.87,10.35-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/><path class="cls-19-6" d="M141.5,190.5v29a142.25,142.25,0,0,0,1,15,9.78,9.78,0,0,0,10,5c7-1,11-4,10-11-.36-2.54-.73-5-1-7.56a138.9,138.9,0,0,1-1-17.44v-14A69.37,69.37,0,0,0,141.5,190.5Z"/><path class="cls-19-7" d="M152.5,190.5s0,7,2,12a55.9,55.9,0,0,1,3.13,17.34c-.12,3.66.88,15.66-3.12,17.66s-2,2-2,2,7-1.57,8.48-3.79,1.94-3.24,1.23-9.23a177.33,177.33,0,0,1-1.71-24.86V189.5a37.46,37.46,0,0,0-6.17-.25l-1.83.25Z"/><path class="cls-19-8" d="M141.5,190.5v29a142.25,142.25,0,0,0,1,15,9.78,9.78,0,0,0,10,5c7-1,11-4,10-11-.36-2.54-.73-5-1-7.56a138.9,138.9,0,0,1-1-17.44v-14A69.37,69.37,0,0,0,141.5,190.5Z"/><path class="cls-19-3" d="M161.25,222.25s-2.67,3.25-9.51,3.25-10.13-2-10.13-2"/><path class="cls-19-3" d="M160,208.5s-3,3-9.06,3S142,210,142,210"/><path class="cls-19-3" d="M160,197.5s-2,2-8.06,2A86.48,86.48,0,0,1,142,199"/><path class="cls-19-9" d="M188,246.51,189.52,262s6.63-4,9.06-6l2.42-2s.72.24.36-3.88-.79-17.93-.79-17.93S200,240,188,246.51Z"/><path class="cls-19-10" d="M84,273.63S97,277,97,285s-1,9,0,14,0,4,0,4H84s3.32-3,2.41-11.26S85,279.5,85,279.5Z"/><path class="cls-19-8" d="M87.5,247.5s-19-10-37-5-29,20-32,37c-1,11,1,16,3,20h64s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-19-9" d="M87.5,247.5a63.65,63.65,0,0,0-25.06-6.39c7.06,2.39,3.06,8.39-8.94,13.39-11,6-23,10-27.5,32.5-.67,7.37.84,13.18,1.5,14.5l58-2s2-5,0-16-3-23-2-29S87.5,247.5,87.5,247.5Z"/><path class="cls-19-11" d="M33.5,299.5s-7-19-3-35a28.63,28.63,0,0,1,17-21.06"/><path class="cls-19-12" d="M62.44,241.12a3.46,3.46,0,0,1,3.06,3.38c0,3,0,4-10,9s-21.45,10-26.22,21-2.62,25-2.62,25H21.5l-3-20S27.38,241.73,62.44,241.12Z"/><path class="cls-19-9" d="M84.26,275.62A31.64,31.64,0,0,1,80.5,290.5a19.71,19.71,0,0,1-11.14,8.83l16.14.17s1.42-2.62.71-10.81A90.57,90.57,0,0,0,84.26,275.62Z"/><path class="cls-19-13" d="M222.5,301.5s-10-35,11-53,58-15,70,2,2,51,2,51Z"/><path class="cls-19-14" d="M292.5,241.5s-12-1-13,15c-1,14,4,41,7,45"/><path class="cls-19-9" d="M226.5,244.5l.74,11a39.65,39.65,0,0,0-7.12,24.19c.38,14.81,2.38,21.81,2.38,21.81h-12s1-9,.5-14.5-1.49-18.55-1.49-18.55S227.5,259.5,226.5,244.5Z"/><path class="cls-19-8" d="M209.51,268.45s18-9,17-24l.74,11a39.65,39.65,0,0,0-7.12,24.19c.38,14.81,2.38,21.81,2.38,21.81h-12"/><path class="cls-19-9" d="M293.9,242.19c-1.9-1.19,6.6,6.31,7.6,15.31s7.11,6.39,7.11,6.39-.11-8.39-5.11-13.39S295.81,243.38,293.9,242.19Z"/><circle cx="154" cy="262" r="1.5"/><circle cx="170.5" cy="259.5" r="1.5"/><circle cx="183.5" cy="255.5" r="1.5"/><circle cx="194.5" cy="250.5" r="1.5"/><circle cx="138.5" cy="261.5" r="1.5"/><circle cx="125.5" cy="259.5" r="1.5"/><circle cx="115.5" cy="255.5" r="1.5"/><path class="cls-19-8" d="M83.5,254.5s-6,28,67,26,77-30,76-36-12.3-10.12-26-13c0,5,1,22,1,22s-8,13-46,17c-29,1-45-9-45-9s-1.5-16-2.5-23.13C96.5,241.75,85.5,247.5,83.5,254.5Z"/></g>"""
|
||||
)
|
||||
)
|
||||
|
||||
private val eyes: List<Part> = listOf(
|
||||
Part(
|
||||
""".cls-20-2{fill-opacity:0.4;}.cls-20-2,.cls-20-3{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-20-3{fill:#461917;stroke-width:0.5px;}""",
|
||||
"""<g id="eyes-01"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M144.5,141.5s5,9,9,7,13-9,13-9v-9s-2-1-7,0-14,5-14,5S142.5,137.5,144.5,141.5Z"/><path class="cls-fill-1" d="M118,141l-5,10s-7.5-2.5-10.5-4.5-4-3-4-6l1-3a13.6,13.6,0,0,1,7,0c4,1,11,2,11,2S118.5,139.5,118,141Z"/></g><path class="cls-20-2" d="M144.5,141.5s5,9,9,7,13-9,13-9v-9s-2-1-7,0-14,5-14,5S142.5,137.5,144.5,141.5Z"/><path class="cls-20-3" d="M144.5,139.5s2,2,6,1,10-6,12-7a18.66,18.66,0,0,0,4-3s-3.22-1-9.11.52a67.92,67.92,0,0,0-11.89,4.48S142.5,137.5,144.5,139.5Z"/><path class="cls-20-2" d="M118,141l-5,10s-7.5-2.5-10.5-4.5-4-3-4-6l1-3a13.6,13.6,0,0,1,7,0,37.46,37.46,0,0,0,8,1S118.5,139.5,118,141Z"/><path class="cls-20-3" d="M100.5,139.5l6,3c2,1,9.7,2.6,11.35-1.2s-11.6-4.69-16-4.24c0,0-3-.17-2.67.94A2.3,2.3,0,0,0,100.5,139.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-21-2{fill-opacity:0.2;}.cls-21-2,.cls-21-3,.cls-21-4,.cls-21-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-21-2,.cls-21-4,.cls-21-5{stroke-width:0.5px;}.cls-21-3,.cls-21-4{fill-opacity:0.4;}.cls-21-5{fill:#461917;}.cls-21-6{fill:#faaf40;}""",
|
||||
"""<g id="eyes-02"><g id="fill_color" data-name="fill color"><path id="fillcolor" class="cls-fill-1" d="M110.5,122.5c-4,0-17,2-17,17s10,17,17,17,15-5,15-18S114.5,122.5,110.5,122.5Z"/><path id="fillcolor-2" data-name="fillcolor" class="cls-fill-1" d="M153.5,121.5c-4,0-17.5,2.5-17.5,17.5s10.5,16.5,17.5,16.5,16-5,16-18C167,122,157.5,121.5,153.5,121.5Z"/></g><path class="cls-21-2" d="M110.5,126.5s-13,0-13,12,10,13,13,13,12-2,12-14.52C122.5,137,121.5,127.5,110.5,126.5Z"/><path class="cls-21-3" d="M154.5,121.5s-20,0-19,18,19,16,19,16,16-2,15-17S158.5,121.5,154.5,121.5Z"/><path class="cls-21-3" d="M110.5,122.5c-4,0-17,2-17,17s10,17,17,17,15-5,15-18S114.5,122.5,110.5,122.5Z"/><path class="cls-21-4" d="M140.1,137.73s-2,13.2,12.18,13.2,14.22-10.15,14.22-13.2-3-12.18-12.18-12.18S140.1,132.65,140.1,137.73Z"/><path class="cls-21-5" d="M109,127s-9,1-9,9,2.5,12.5,10.5,12.5,12-5,12-10S117.5,126.5,109,127Z"/><path class="cls-21-5" d="M154,126s-10.5.52-11,9.4S147,149,154,149s12-6.26,12-11.49S161.5,125.51,154,126Z"/><path class="cls-21-6" d="M110.26,134.06a.92.92,0,0,0-.24.05c-.36.13-1,.61-1,2.31,0,2.36,0,3.54,1.26,3.54s2.51,0,2.51-3.54C112.77,134.06,111.12,133.86,110.26,134.06Z"/><path class="cls-21-6" d="M154.26,134.06a.92.92,0,0,0-.24.05c-.36.13-1,.61-1,2.31,0,2.36,0,3.54,1.26,3.54s2.51,0,2.51-3.54C156.77,134.06,155.12,133.86,154.26,134.06Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-22-2{opacity:0.4;}.cls-22-3{fill:#461917;}.cls-22-3,.cls-22-4,.cls-22-5{stroke:#000;}.cls-22-3,.cls-22-5{stroke-linecap:round;stroke-linejoin:round;}.cls-22-3,.cls-22-4{stroke-width:0.5px;}.cls-22-4{fill:#ec1c24;stroke-miterlimit:10;}.cls-22-5{fill:none;stroke-width:0.75px;}""",
|
||||
"""<g id="eyes-03"><path class="cls-fill-1" d="M135.5,123.5s-34,1-35,17,23,12,23,12,23-2,28-3,18-5,18-14-8-13-17-13S139.5,123.5,135.5,123.5Z"/><path class="cls-22-2" d="M117.31,152.44c-4.51,0-10.59-.74-13.91-4.27a9.8,9.8,0,0,1-2.41-7.64c1-15.37,34.18-16.52,34.52-16.53h0c1.61,0,3.25-.16,5.15-.35A107.89,107.89,0,0,1,152.5,123c8,0,16.5,3.28,16.5,12.5,0,9.71-15.8,13.15-17.6,13.51-4.92,1-27.72,3-27.95,3a42,42,0,0,1-6.14.44Z"/><path d="M152.5,123.5c7.73,0,16,3.15,16,12,0,7.56-10.81,11.74-17.2,13-4.89,1-27.66,3-27.89,3h-.08a41.19,41.19,0,0,1-6,.43c-4.41,0-10.35-.71-13.54-4.12a9.32,9.32,0,0,1-2.27-7.27c.93-14.91,33.7-16.05,34-16.06,1.65,0,3.3-.17,5.22-.36a107.45,107.45,0,0,1,11.78-.64m0-1c-9,0-13,1-17,1,0,0-34,1-35,17-.67,10.67,9.78,12.44,16.81,12.44a41.73,41.73,0,0,0,6.19-.44s23-2,28-3,18-5,18-14-8-13-17-13Z"/><path class="cls-22-3" d="M106.5,132.5s-2,2-1,6,2,9,16,8a306.78,306.78,0,0,0,31-4c6-1,16-1.5,17-6.25s-2.18-11.89-12.09-13.32-12.93-.11-16.92.23-8.25.45-11.62.89-16.37,2.44-22.37,8.44"/><path class="cls-22-4" d="M134,124s-4,2-4,6,3.5,11.5,10.5,10.5,9-5,9-11-6.58-6.54-6.58-6.54-3.42.54-4.42.54S134,124,134,124Z"/><ellipse cx="140" cy="130.5" rx="2" ry="2.5"/><path class="cls-22-5" d="M107.5,131.5s-3,2-2,7c1,4,2,9,16,8a306.78,306.78,0,0,0,31-4c6-1,16-1.5,17-6.25s-2.18-11.89-12.09-13.32-12.93-.11-16.92.23-8.25.45-11.62.89-15.37,1.44-21.37,7.44"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-23-2,.cls-23-3{fill:#fff;}.cls-23-2{fill-opacity:0.4;}.cls-23-3{fill-opacity:0.2;}.cls-23-3,.cls-23-4,.cls-23-5{stroke:#000;stroke-miterlimit:10;}.cls-23-4{fill:none;}.cls-23-4,.cls-23-5{stroke-width:0.75px;}.cls-23-5{fill:red;}""",
|
||||
"""<g id="eyes-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M127.5,127.5c11.08-.53,43-2,43,11,1,15-16.69,15-23.34,15.52s-37.66,1.48-47.66.48-15-4-15-12,8-11,17-13S121,127.81,127.5,127.5Z"/><path class="cls-23-2" d="M94.39,132.09s2.78-4.25,37.95-4.92,37.17,6.33,38.17,11.33-3,10.76-6,11.88c0,0,5.52-6.38-2.24-11.63s-22.72-6.58-29.74-6.91-24.86-.88-27.94-.61S96.28,131.67,94.39,132.09Z"/><path class="cls-23-3" d="M127.5,127.5c11.08-.53,43-2,43,11,1,15-16.69,15-23.34,15.52s-37.66,1.48-47.66.48-15-4-15-12,8-11,17-13S121,127.81,127.5,127.5Z"/><path class="cls-23-4" d="M121.5,131.5c-19,0-36-3-37,11s19,12,40,12,42,0,42-9C166.5,134.5,140.5,131.5,121.5,131.5Z"/><path class="cls-23-5" d="M121.5,140.5s29,0,32,1,2,4-1,4-22-1-31-1-22,1-26,0-1-2,11-3S121.5,140.5,121.5,140.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-24-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-24-2,.cls-24-3,.cls-24-5{stroke:#000;}.cls-24-2,.cls-24-5{stroke-width:0.75px;}.cls-24-3,.cls-24-5{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-24-3{stroke-width:0.5px;}.cls-24-4{fill:#ec1c24;}""",
|
||||
"""<g id="eyes-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M131.5,119.5c-5,0-19,2-19,16,0,15,10,18,18,18s17-7,16-19S136.5,119.5,131.5,119.5Z"/><path class="cls-24-2" d="M131.5,119.5c-5,0-19,2-19,16,0,15,10,18,18,18s17-7,16-19S136.5,119.5,131.5,119.5Z"/><path class="cls-24-2" d="M132,124l-.5,0c-2.51,0-13.61.38-14.5,11-1,12,8.5,13.5,12.5,13.5s13-3,13-13C142.5,124.5,132,124,132,124Z"/><path class="cls-24-3" d="M130.5,124.5s-10,1-10,10,5,11,11,11a11.1,11.1,0,0,0,11-11C142.5,128.5,136.5,124.5,130.5,124.5Z"/><ellipse class="cls-24-4" cx="131.5" cy="134.5" rx="2" ry="3"/><path class="cls-24-5" d="M147.5,133.5h6s1,0,0,1a7.69,7.69,0,0,1-3,2h-4v-2Z"/><path class="cls-24-5" d="M112.49,135.15a29.28,29.28,0,0,1-3.29.35c-1.1,0-1.7,1-2.2,2a.79.79,0,0,0,.2.57,1.56,1.56,0,0,0,.94.36,18.66,18.66,0,0,0,2.15.07c2.2,0,2.32-.35,2.32-.35Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-25-2{fill-opacity:0.55;stroke-miterlimit:10;stroke-width:0.75px;}.cls-25-2,.cls-25-3{stroke:#000;}.cls-25-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}.cls-25-4{fill:#ec1c24;}""",
|
||||
"""<g id="eyes-06"><g id="fill_color" data-name="fill color"><path class="cls-fill-1" d="M112.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C123,130.5,112.71,130,112.71,130Z"/><path class="cls-fill-1" d="M156.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C167,130.5,156.71,130,156.71,130Z"/></g><path class="cls-25-2" d="M112.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C123,130.5,112.71,130,112.71,130Z"/><path class="cls-25-3" d="M111.23,130.5s-9.8,1-9.8,10,4.9,11,10.78,11a11,11,0,0,0,10.78-11C123,134.5,117.12,130.5,111.23,130.5Z"/><ellipse class="cls-25-4" cx="112.22" cy="140.5" rx="1.96" ry="3"/><path class="cls-25-2" d="M156.71,130l-.49,0c-2.46,0-13.35.38-14.22,11-1,12,8.33,13.5,12.26,13.5s12.75-3,12.75-13C167,130.5,156.71,130,156.71,130Z"/><path class="cls-25-3" d="M155.23,130.5s-9.8,1-9.8,10,4.9,11,10.78,11a11,11,0,0,0,10.78-11C167,134.5,161.12,130.5,155.23,130.5Z"/><ellipse class="cls-25-4" cx="156.22" cy="140.5" rx="1.96" ry="3"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-26-2{fill-opacity:0.6;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-5,.cls-26-6{stroke:#000;}.cls-26-2,.cls-26-3,.cls-26-4,.cls-26-6{stroke-linecap:round;stroke-linejoin:round;}.cls-26-3{fill:#461917;}.cls-26-3,.cls-26-4,.cls-26-5{stroke-width:0.5px;}.cls-26-4,.cls-26-5{fill:#f9ec31;}.cls-26-5{stroke-miterlimit:10;}.cls-26-6{fill:none;}""",
|
||||
"""<g id="eyes-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/><path class="cls-26-2" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/><path class="cls-26-3" d="M107.16,130.47S105,133,105,138s2,9,8,10,20.5-1.5,20.5-1.5,11-2,18-2,15.74-2.78,15.87-5.89-.17-7.11-2.52-10.11-7.12-3.56-10.24-3.78-12.52-.54-14.82-.38-10.17.47-12.23.81-7.31,1.1-7.31,1.1l-7.61,1.87S107.83,129.44,107.16,130.47Z"/><path class="cls-26-4" d="M109,135s-1,11,10,10,10-10,10-10,0-6-3-8-2.85-1.25-2.85-1.25-11.28,2.64-12.21,2.94S109,133,109,135Z"/><path class="cls-26-5" d="M142,125s-3,2-3,7,3.5,10.5,9.5,10.5,12-4,12-10-3.66-7.5-3.66-7.5l-4.78-.47-4.22-.16h-3.44l-1.91.13Z"/><path d="M119.42,133s-1.17,0-1.17,2.5,1.17,2.5,1.17,2.5a2.51,2.51,0,0,0,0-5Z"/><path d="M150.08,130a1.8,1.8,0,0,0-1.82,1.94c-.07,2,.78,3,1.65,3.06s1.78-.94,1.85-2.94a1.8,1.8,0,0,0-1.68-2.06"/><path class="cls-26-6" d="M99.5,142.5s0-10,12-14,26-4,26-4,20-1,25,2,5,9,5,11-2,11-19,13-33,3-33,3S99.5,153.5,99.5,142.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-27-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-27-2,.cls-27-3,.cls-27-4{stroke:#000;}.cls-27-3,.cls-27-4{fill:#461917;stroke-linecap:round;stroke-linejoin:round;}.cls-27-3{stroke-width:0.5px;}.cls-27-4{stroke-width:0.75px;}""",
|
||||
"""<g id="eyes-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M110,135s-1.5,6.5.5,6.5h45v-6s0-2-3-2h-38C111.5,133.5,110.5,133.5,110,135Z"/><path class="cls-27-2" d="M110,135s-1.5,6.5.5,6.5h45v-6s0-2-3-2h-38C111.5,133.5,110.5,133.5,110,135Z"/><path class="cls-27-3" d="M110.83,133.88a25.17,25.17,0,0,0,2.67,4.62c1,1,5,0,9,0h31s2-.12,2,.94V135.5s0-2-3-2H113.31Z"/><line class="cls-27-4" x1="113.5" y1="138.5" x2="109.9" y2="141.21"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-28-2{fill:none;}.cls-28-2,.cls-28-3,.cls-28-4,.cls-28-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-28-2,.cls-28-4,.cls-28-6{stroke-width:0.75px;}.cls-28-3{fill:#461917;stroke-width:0.5px;}.cls-28-4{fill-opacity:0.4;}.cls-28-5{fill:#fff100;}.cls-28-6{fill:#fff;fill-opacity:0.2;}""",
|
||||
"""<g id="eyes-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M127.5,124.5s-43-1-45,16c-1.5,15.5,25,13,25,13h35s27,0,27-13S158.5,123.5,127.5,124.5Z"/><path class="cls-28-2" d="M127.5,124.5s-43-1-45,16c-1,15,21,13,25,13h35s27,0,27-13S158.5,123.5,127.5,124.5Z"/><path class="cls-28-3" d="M126,132c-15.5.5-28.5-1.5-30.5,6.5-2,9,16,7,22,7s27-1,30-1,10-1,10-3C157.5,133.5,142.5,131.5,126,132Z"/><path class="cls-28-4" d="M90.5,140.5c0,1-5,9,32,8s35-4.33,35-7.67-8.64-9.23-21.32-8.78S95.5,128.5,90.5,140.5Z"/><ellipse class="cls-28-5" cx="112" cy="139" rx="2.5" ry="3.5"/><ellipse class="cls-28-5" cx="138" cy="139" rx="2.5" ry="3.5"/><path class="cls-28-6" d="M93.5,129.5s27-3,41-2c11,0,24,3,29,10s1.39,10.59-.3,11.8,6.3-2.8,6.3-8.8-1.61-11.29-14.3-14.65-30.2-1.18-33.95-1.26S98.5,126.5,93.5,129.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-29-2{fill:#fff;}.cls-29-2,.cls-29-4{fill-opacity:0.4;}.cls-29-3{fill:none;}.cls-29-3,.cls-29-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-29-4{stroke-width:0.75px;}""",
|
||||
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M129.5,123.5s-30,3-31,15,8,13,15,14,20-2,26-3,25,1,27-13-17-14-17-14Z"/><path class="cls-29-2" d="M116.92,125.8s-15.42,2.7-16.42,12.7,15.5,13,24.25,10.5A97.37,97.37,0,0,1,150,145.44c6.5.06,16.78-3.69,16.64-11.31S157.5,122.5,149.5,122.5s-21.12,1.13-21.12,1.13Z"/><path class="cls-29-2" d="M130.5,123.5l-8,25.5c1.6-.06,5.38-.65,9-1.2l7-24.8A47.26,47.26,0,0,1,130.5,123.5Z"/><polygon class="cls-29-2" points="120.77 124.91 113 150 118 150 125.41 124.06 120.77 124.91"/><path class="cls-29-3" d="M129.5,123.5s-30,3-31,15,8,13,15,14,20-2,26-3,25,1,27-13-17-14-17-14Z"/><path class="cls-29-4" d="M106.61,129.46s-6.11,3-6.11,9,5,13,20,11,23-4,28-4,17.29-2.5,18.15-10.25c0,0,.3,10.92-14.42,13.09s-16.25,1.2-21,2.68-28.06,4.23-32.4-7.15C98.84,143.87,95.71,134.43,106.61,129.46Z"/>"""
|
||||
)
|
||||
)
|
||||
|
||||
private val faces: List<Part> = listOf(
|
||||
Part(
|
||||
""".cls-30-2{fill:#fff;fill-opacity:0.4;}.cls-30-3,.cls-30-4{fill:none;}.cls-30-3,.cls-30-4,.cls-30-6{stroke:#000;}.cls-30-3,.cls-30-6{stroke-linecap:round;stroke-linejoin:round;}.cls-30-4{stroke-miterlimit:10;}.cls-30-5,.cls-30-6{fill-opacity:0.2;}.cls-30-6{stroke-width:0.75px;}""",
|
||||
"""<g id="face-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M144.5,87.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s13.5-1.5,19-1,14,2,14,2,6-13,19-19,18-8,18-8-4-35-4-52S201.5,88.5,144.5,87.5Z"/><path class="cls-30-2" d="M115.14,97.69s6.36,5.81-2.64,14.81-16,23-13,44,10,40,10,50-.67,18.19-.67,18.19l-2.33-2.19s-15-45-15-76.5S115.14,97.69,115.14,97.69Z"/><path class="cls-30-3" d="M144.5,87.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s9-1,15-1,19,1,19,1a38.06,38.06,0,0,1,18-18c13-6,18-8,18-8s-4-35-4-52S201.5,88.5,144.5,87.5Z"/><path class="cls-30-4" d="M144.5,86.5s-51,3-53,55c0,0,0,27,5,42s10,38,10,38l16,16,1,5s9-1,15-1,19,1,19,1a38.06,38.06,0,0,1,18-18c13-6,18-8,18-8s-4-35-4-52S201.5,87.5,144.5,86.5Z"/><path class="cls-30-5" d="M158.5,92.5s20,15,18,32-8,28-12,29a19.27,19.27,0,0,1,8,16c0,11,1,50,1,50l.34,6.83,19.66-8.83s-3.77-39-3.38-63.49,1.38-55.13-31.62-64.82C158.5,89.19,155.5,90.5,158.5,92.5Z"/><path class="cls-30-3" d="M124.5,211.5l37-1,4.76,21.18s-8.76,8.82-8.76,11.82c-2,1-10-1-18-1a147.84,147.84,0,0,0-16,1S118.5,224.5,124.5,211.5Z"/><path class="cls-30-6" d="M159.5,212.5a19.89,19.89,0,0,0-4,14c1,8,2,17,2,17l9-12-5-21Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-31-2,.cls-31-4{fill-opacity:0.2;}.cls-31-3{fill:none;}.cls-31-3,.cls-31-4{stroke:#000;stroke-miterlimit:10;}.cls-31-4,.cls-31-5{fill:#fff;}.cls-31-4{stroke-width:0.75px;}.cls-31-5{fill-opacity:0.4;}""",
|
||||
"""<g id="face-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M147.5,88.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S172,87,147.5,88.5Z"/><path class="cls-31-2" d="M154.5,90.5s18,12,25,47,2,48-7,55-24.12,17.37-42.56,17.68c0,0,56.6,1.84,70.58-33.42.73-1.85,1.84-5.7,1.84-5.7S185.5,158.5,183.17,137c-1.67-14.45,2.33-24.45,6.49-24.63a7,7,0,0,0-.63-1.08C179.68,98,160,87,154.5,90.5Z"/><path class="cls-31-3" d="M147.5,87.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S170.5,85.5,147.5,87.5Z"/><path class="cls-31-3" d="M147.5,88.5s-64,9-63,72c0,0-6,51,54,50,0,0,66-6,65-53S171.5,86.5,147.5,88.5Z"/><path class="cls-31-4" d="M92.22,125.67s2.28,14.83.28,24.83-7.67,21-7.67,21S80.94,147.85,92.22,125.67Z"/><path class="cls-31-4" d="M187.5,113.5s-7,10-4,26,12,27.67,18.5,29.83c0,0,5.83-35.17-12.83-57Z"/><path class="cls-31-5" d="M112.5,112.5s-5-1-10,6-6,11-5,13,7,1,11-7S114.5,114.5,112.5,112.5Z"/><path class="cls-31-5" d="M117.5,102.5s-5,4-2,7a6.31,6.31,0,0,0,9,0c2-2,2-6-1-7A10.56,10.56,0,0,0,117.5,102.5Z"/><circle cx="188" cy="143" r="1.5"/><circle cx="193.5" cy="154.5" r="1.5"/><circle cx="199.5" cy="161.5" r="1.5"/><circle cx="88.5" cy="154.5" r="1.5"/><circle cx="89.5" cy="143.5" r="1.5"/><circle cx="90.5" cy="133.5" r="1.5"/><circle cx="186.5" cy="130.5" r="1.5"/><circle cx="190.5" cy="119.5" r="1.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-32-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-32-3{fill-opacity:0.2;}.cls-32-4{fill:#fff;fill-opacity:0.4;}""",
|
||||
"""<g id="face-03"><path class="cls-fill-1" d="M147,88c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,89.24,147,88Z"/><path class="cls-32-2" d="M147,88c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,89.24,147,88Z"/><path class="cls-32-2" d="M147,87c-20.15-.5-56.5,14.5-56.5,57.5,0,0,1,22,6,41s10,34,10,37c0,0,3,12,25,11s62-15,62-15-2-31-3-49S196.79,88.24,147,87Z"/><path class="cls-32-3" d="M135.5,96.5s36-4,45,43c0,0,3,24,3,35s-1,25,1,36l2,11,7-2s-2.53-55.76-3.27-70.88S188.5,90.81,150,88.16c0,0-19.78-1-33.64,8.7,0,0-4.86,4.65.14,2.65S132.5,96.5,135.5,96.5Z"/><path class="cls-32-4" d="M122.5,104.5s-4,1-4,4,2,7,5,6,5-5,4-7S125.5,103.5,122.5,104.5Z"/><path class="cls-32-4" d="M115.5,111.5s-9-6-17,12-1,37-1,37,2,6,8,6,4-9,4-9-4-13-1-24,7-13,7-13S120.5,115.5,115.5,111.5Z"/><circle cx="111" cy="223" r="1.5"/><circle cx="125.5" cy="227.5" r="1.5"/><circle cx="141.5" cy="226.5" r="1.5"/><circle cx="159.5" cy="223.5" r="1.5"/><circle cx="175.5" cy="219.5" r="1.5"/><circle cx="188.5" cy="214.5" r="1.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-33-2{fill-opacity:0.2;}.cls-33-3{fill:#fff;fill-opacity:0.4;}.cls-33-4{fill:none;stroke:#000;stroke-miterlimit:10;}""",
|
||||
"""<g id="face-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148,87s-56.5.5-56.5,63.93,33,84.57,48,84.57,45-8.05,51-41.28c0,0,1-16.11,0-25.17s-1-29.2-1-32.22S186.5,88.51,148,87Z"/><path class="cls-33-2" d="M151.5,92.5s17,12,22,42,7,44,3,63S167,226,161.75,229.25s21.67-4.94,26.71-26.84,1.77-35.94,1.77-35.94l-1-32.24s-5-35.64-26.88-43.18c0,0-11.87-4.54-17.37-3S148.5,89.5,151.5,92.5Z"/><path class="cls-33-3" d="M113.5,110.5s-4,0-4,4,4,7,7,5,5-5,4-7S118.5,108.5,113.5,110.5Z"/><path class="cls-33-3" d="M108.5,123.5s-4-1-7,5-4,15-3,24a33.42,33.42,0,0,0,8,18c2,2,6,3,5-4s-5-14-4-25S114.5,124.5,108.5,123.5Z"/><path class="cls-33-4" d="M148,87s-56.5.5-56.5,63.5,33,84,48,84,45-8,51-41c0,0,1-16,0-25s-1-29-1-32S186.5,88.5,148,87Z"/><path class="cls-33-4" d="M148,86s-56.5.5-56.5,63.5,33,84,48,84,45-8,51-41c0,0,1-16,0-25s-1-29-1-32S186.5,87.5,148,86Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-34-2,.cls-34-3{fill:#fff;}.cls-34-2{fill-opacity:0.4;}.cls-34-3,.cls-34-5{fill-opacity:0.2;}.cls-34-3,.cls-34-4{stroke:#000;stroke-miterlimit:10;}.cls-34-4{fill:none;}""",
|
||||
"""<g id="face-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M91.5,107.5s4,64,4,77,1,38,1,38,4,11,22,10c36.5.5,60-11,66-16,0,0-3-106-3-116s4-20-36-22c0,0-35.59,1.45-44.8,8.73,0,0-10.2,2.27-9.2,7.27S91.5,107.5,91.5,107.5Z"/><path class="cls-34-2" d="M95.8,102s2.7,53.53,3.7,68.53,5,42,5,45v14.71s-7-3.71-8-7.71-3.65-88.21-3.83-95.61S91.5,107.5,91.5,107.5v-13S91.1,100.43,95.8,102Z"/><path class="cls-34-3" d="M145.5,78.5s-53,5-54,16,23,14,37,13,52-7,53-16S156.5,77.5,145.5,78.5Z"/><path class="cls-34-4" d="M91.5,107.5s4,64,4,77,1,38,1,38,3,9,22,10c32,2,60-11,66-16,0,0-3-106-3-116s4-20-36-22c-16,.67-31.14,3.13-44.8,8.73-4.7,1.42-7.78,3.83-9.2,7.27Z"/><path class="cls-34-5" d="M168.5,103.5s1,49,1,57,1,35,1,41-1,22.92-1,22.92l15-7.92s-2.94-96.67-3-100.83,0-25.17,0-25.17c.34,3.51-3.36,6.57-11,9.17l-2,.72Z"/><circle cx="148" cy="114" r="1.5"/><circle cx="130.5" cy="116.5" r="1.5"/><circle cx="114.5" cy="117.5" r="1.5"/><circle cx="116.5" cy="225.5" r="1.5"/><circle cx="101.5" cy="220.5" r="1.5"/><circle cx="132.5" cy="225.5" r="1.5"/><circle cx="150.5" cy="221.5" r="1.5"/><circle cx="166.5" cy="217.5" r="1.5"/><circle cx="179.5" cy="212.5" r="1.5"/><circle cx="99.5" cy="112.5" r="1.5"/><circle cx="164.5" cy="109.5" r="1.5"/><circle cx="177.5" cy="104.5" r="1.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-35-2{fill:none;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-35-3{fill-opacity:0.2;}.cls-35-4{fill:#fff;fill-opacity:0.4;}""",
|
||||
"""<g id="face-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M156,83s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,81.5,156,83Z"/><path class="cls-35-2" d="M156,83s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,81.5,156,83Z"/><path class="cls-35-2" d="M156,82s-55,5-66,53,9.5,66.5,9.5,66.5,9,8,12,8,2-2,1-4a7.47,7.47,0,0,0-3-3s4,1,5,10,1,32,1,38v12s2,8,17,8,30-7,30-7-4-34-2-41,9-20,15-26,3-1,0,2a50.18,50.18,0,0,0-6,8s17-7,23-14,15-10,16-33S214.5,80.5,156,82Z"/><path class="cls-35-3" d="M181.5,93.5s18,16,16,44-6,41-28,54c0,0-16,3-19,28s0,49.27,0,49.27l12-4.27s-6.87-36.09.07-46.54,7.21-11.15,17.07-15.3,30.94-21.59,29.4-50.87-.09-56-35.32-67.16l-3.94-.77S168.5,84.5,181.5,93.5Z"/><path class="cls-35-4" d="M110.5,117.5s-7,1-10,11-11,25-8,35,7,10,9,5,1-17,5-28S116.5,119.5,110.5,117.5Z"/><path class="cls-35-4" d="M122.5,99.5s-9,6-9,9,0,4,5,4,13-4,13-9S128.5,96.5,122.5,99.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-36-2,.cls-36-6{fill:#fff;}.cls-36-2{fill-opacity:0.4;}.cls-36-3,.cls-36-4{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-36-3{stroke-width:2px;}.cls-36-5,.cls-36-6{fill-opacity:0.2;}""",
|
||||
"""<g id="face-07"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M71.5,130.5a51.83,51.83,0,0,0,6,29c8,15,39,71,66,70s76-50,76-93-38-54-77-53S73.5,102.5,71.5,130.5Z"/><path class="cls-36-2" d="M116.5,88s-24.18,5.5-35.59,20.5-9.79,26-9.1,34.48,8.2,22.55,14.44,33,18.64,28.81,22.95,32.65,11.3,11.84,11.3,11.84.3-6.17-5.85-18.58S83.5,164,81.5,143.74,87.5,101.49,116.5,88Z"/><path class="cls-36-3" d="M71.5,130.5a51.83,51.83,0,0,0,6,29c8,15,39,71,66,70s76-50,76-93-38-54-77-53S73.5,102.5,71.5,130.5Z"/><path class="cls-36-4" d="M75.44,115.88S108.5,100.5,140.5,101.5s65,7,67,30-3,34-9,50a89.16,89.16,0,0,1-16.32,27"/><path d="M80,118.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S80,118.5,80,118.5Z"/><path d="M73,133.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S73,133.5,73,133.5Z"/><path d="M98,112.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S98,112.5,98,112.5Z"/><path d="M118,109.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S118,109.5,118,109.5Z"/><path d="M143,107.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S143,107.5,143,107.5Z"/><path d="M164,107.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S164,107.5,164,107.5Z"/><path d="M184,112.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S184,112.5,184,112.5Z"/><path d="M201,125.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S201,125.5,201,125.5Z"/><path d="M202,149.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S202,149.5,202,149.5Z"/><path d="M195,173.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S195,173.5,195,173.5Z"/><path d="M185,195.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S185,195.5,185,195.5Z"/><path d="M169,210.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S169,210.5,169,210.5Z"/><path d="M141,220.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S141,220.5,141,220.5Z"/><path d="M115,209.5s-1.5,0-1.5,2,1.5,2,1.5,2,1.5,0,1.5-2S115,209.5,115,209.5Z"/><path class="cls-36-5" d="M199.5,115.5l-9-5.07S188,110,191,120s6.5,25.5,4.5,40.5-7,27-19,40-17.37,20.78-17.19,23.89S184.5,208.5,188.5,202.5s11.93-23.83,16-39S211.5,124.5,199.5,115.5Z"/><path class="cls-36-6" d="M201,100l-10,10s-23.39-8.18-41.7-8.09-33.49-1.25-53.4,6.42L76,116s-.08-2.83,6.71-9.66,18-18.34,48.89-22.09,49.94,2.2,58.92,6.73,12,7.09,12,7.09Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-37-2{fill:#fff;fill-opacity:0.4;}.cls-37-3{fill:none;}.cls-37-3,.cls-37-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-37-4{fill-opacity:0.2;}""",
|
||||
"""<g id="face-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M212.5,90.5s-19.5-20.5-55-12c-31,8-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,96.5,212.5,90.5Z"/><path class="cls-37-2" d="M130.5,88.5s-9,6-12,9-16,15-18,28,2,38,4,46,5,21,4,26-2.93,6.66-2.93,6.66S99.5,183.5,97.5,173.5s-6-27-1.52-51S130.5,85.5,130.5,88.5Z"/><path class="cls-37-3" d="M212.5,90.5s-18.25-20.21-55-12c-31.25,7-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,96.5,212.5,90.5Z"/><path class="cls-37-3" d="M212.5,89.5s-18-19-55-12c-31.23,7.06-61,14-64,60a122.71,122.71,0,0,0,4,35c5,19,11,42,12,45,0,0,8,9,25,9s56-10,56-10,11-5,12-6-20-60-11-86c0,0,3-7,14-8s14,0,14,0S217.5,95.5,212.5,89.5Z"/><path class="cls-37-4" d="M209.5,92.5s-15,0-21,6-16,16-13,41,12,65,12,65l3,13,12-6s-22.77-76.47-7.39-90.23c0,0,2.39-4.77,24.39-3.77,0,0-.4-21-5.7-25Z"/><circle cx="211" cy="103" r="1.5"/><circle cx="195.5" cy="207.5" r="1.5"/><circle cx="182.5" cy="213.5" r="1.5"/><circle cx="165.5" cy="217.5" r="1.5"/><circle cx="146.5" cy="220.5" r="1.5"/><circle cx="129.5" cy="220.5" r="1.5"/><circle cx="114.5" cy="216.5" r="1.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-38-2{fill:none;stroke:#000;stroke-miterlimit:10;}.cls-38-3,.cls-38-6{fill:#fff;}.cls-38-3,.cls-38-4{fill-opacity:0.2;}.cls-38-5{fill-opacity:0.1;}.cls-38-6{fill-opacity:0.4;}""",
|
||||
"""<g id="face-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M143,89s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,85.5,143,89Z"/><path class="cls-38-2" d="M143,88s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,84.5,143,88Z"/><path class="cls-38-3" d="M99,121.07S122,96,139.5,98.5a137.38,137.38,0,0,1,21.88,7.59s4.12-7.59,6.12-8.59-17-9-23-9C133,89,109.5,104.64,99,121.07Z"/><path class="cls-38-2" d="M143,89s-39,6-64,66,15.5,70.5,34.5,74.5,85,13,102-27S175.5,85.5,143,89Z"/><path class="cls-38-4" d="M161.38,106.09a20.31,20.31,0,0,0-1.88,4.41c-1,6,2,11,9,19s20,27,21,45,1,35-13,45-24.21,14.08-24.21,14.08,34.71.42,47.8-19.51c11.41-15.58,10.59-36,0-59.77-11.09-27.31-33.81-44.6-33.81-44.6Z"/><path class="cls-38-5" d="M167.41,97.44c-2.41-.44-5.91,9.06-5.91,9.06s27,14,39,48,8.86,41,5.43,50-17.62,24.63-36,27.81,37.6-2.82,45.6-29.82c11.5-39.5-22.87-80.23-22.87-80.23S175,101,167.41,97.44Z"/><path class="cls-38-6" d="M91.5,140.5s-14,24-12,43,12,26,19,31,9.2,13.33,9.2,13.33S72,224,71.08,186.84c0,0,3.19-39.76,30.31-68.55L113.22,108S95.5,132.5,91.5,140.5Z"/><circle cx="138.5" cy="106.5" r="2"/><circle cx="111" cy="115" r="2"/><circle cx="164" cy="117" r="2"/><circle cx="185" cy="136" r="2"/><circle cx="194" cy="158" r="2"/><circle cx="201" cy="181" r="2"/><circle cx="200" cy="205" r="2"/><circle cx="183" cy="218" r="2"/><circle cx="164" cy="223" r="2"/><circle cx="142" cy="223" r="2"/><circle cx="118" cy="221" r="2"/><circle cx="97" cy="218" r="2"/><path class="cls-38-2" d="M139.5,98.5s35,3,57,48,13.83,72-24.08,85c-54.92,7-79.92-7-91-16.46-1.5.92-21-20.3-3-60.55C95.74,116,122.5,97.5,139.5,98.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-39-2{fill:#fff;fill-opacity:0.4;}.cls-39-3{fill:none;}.cls-39-3,.cls-39-4{stroke:#000;stroke-miterlimit:10;}.cls-39-4{fill-opacity:0.2;stroke-width:0.75px;}""",
|
||||
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M177,71s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S203,72,177,71Z"/><path class="cls-39-2" d="M135.5,78.5s1,4-5,9-20,12-25,31,0,29,2,37,7,25,6,35-4.75,16-4.75,16-21.38-60.92-12.82-89S129,81,135.5,78.5Z"/><path class="cls-39-3" d="M177,71s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S200.5,70.5,177,71Z"/><path class="cls-39-3" d="M177,70s-73-2-82,51c0,0-4.5,17.5,1.5,40.5s14,50,14,50,6,10,46,6,75-25,75-25-10-100-16-108S200.5,69.5,177,70Z"/><path class="cls-39-4" d="M205.5,86.5s-27,2-29,28,9,73,9,73l5.38,23.67L231.5,193.5s-8-94-15-106C216.48,87.51,208.5,86.5,205.5,86.5Z"/><circle cx="147.5" cy="214.5" r="1.5"/><circle cx="165.5" cy="211.5" r="1.5"/><circle cx="183.5" cy="206.5" r="1.5"/><circle cx="196.5" cy="202.5" r="1.5"/><circle cx="208.5" cy="198.5" r="1.5"/><circle cx="219.5" cy="194.5" r="1.5"/><circle cx="228.5" cy="190.5" r="1.5"/><circle cx="130.5" cy="214.5" r="1.5"/><circle cx="115.5" cy="210.5" r="1.5"/>"""
|
||||
)
|
||||
)
|
||||
|
||||
private val mouths: List<Part> = listOf(
|
||||
Part(
|
||||
""".cls-40-2{fill-opacity:0.4;stroke:#000;stroke-linecap:round;stroke-linejoin:round;}""",
|
||||
"""<g id="mouth-01"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M123,183l-1,9a26.74,26.74,0,0,0,3.5.5c2,.44,22,.35,23-1l-1-9S132.5,181.5,123,183Z"/><path class="cls-40-2" d="M123,183l-1,9a26.74,26.74,0,0,0,3.5.5c2,.44,22,.35,23-1l-1-9S132.5,181.5,123,183Z"/><path d="M123.5,183.34s3.07,2.66,12.26,2.66S147,182.45,147,182.45a113.13,113.13,0,0,0-12.2-.28C129.63,182.45,124.52,182.45,123.5,183.34Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-41-2,.cls-41-4{fill:none;}.cls-41-2,.cls-41-3,.cls-41-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-41-3{fill-opacity:0.4;}.cls-41-3,.cls-41-4{stroke-width:0.75px;}""",
|
||||
"""<g id="mouth-02"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M126,180a129.54,129.54,0,0,1,3,25,19.93,19.93,0,0,0,10,1,24.23,24.23,0,0,0,8.5-3.5s-4-23-4-25c0,0-6,2-9,2-1.54,0-3.73.13-5.51.26S126,180,126,180Z"/><path class="cls-41-2" d="M126,180a129.54,129.54,0,0,1,3,25,19.93,19.93,0,0,0,10,1,24.23,24.23,0,0,0,8.5-3.5s-4-23-4-25c0,0-6,2-9,2-1.54,0-3.73.13-5.51.26S126,180,126,180Z"/><path class="cls-41-3" d="M131.5,190.71a113.49,113.49,0,0,1,1,12.25l-3,1.54s-1-14-2.28-18.07c-.72-2.86-1.15-6.11-1.15-6.11l3.43.18S130.5,182.55,131.5,190.71Z"/><path class="cls-41-3" d="M129.29,205.11l3.21-1.61a32,32,0,0,0,7-1,53.36,53.36,0,0,0,7.56-2.57l.44,2.57s-4.93,3.27-7,3.14S138.07,207.71,129.29,205.11Z"/><path class="cls-41-4" d="M131.81,193.42c.93.47,1.84.92,1.69-.92,0,0,0-2,1-2s7-1,7-1a1,1,0,0,1,1,1c0,1,0,3,1,2a3.44,3.44,0,0,1,2.12-1"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-42-2{fill-opacity:0.4;}.cls-42-2,.cls-42-5,.cls-42-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-42-3{fill:#ee4036;}.cls-42-4{fill:#f05a28;}.cls-42-5{fill:#faaf40;stroke-width:0.75px;}.cls-42-6{fill:none;}""",
|
||||
"""<g id="mouth-03"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M170.5,168.5S151,177,141,179l-1,.2a112,112,0,0,1-32.68,1.74q-1.88-.18-3.82-.44l6,20a51.86,51.86,0,0,0,26,5c15-1,35-7,41-15Z"/><path class="cls-42-2" d="M110.5,199.5l2-2s-3-10-4-13-1.87-3.63-1.87-3.63l-3.13-.37,3.38,11.26,2.5,8.33Z"/><path class="cls-42-3" d="M118,181s3.13,7.7,4.06,9.85,2.31,5.09,2.12,7.12h0l.19,4a72.8,72.8,0,0,1,7.93.57C135,203,144,201,144,201a14.36,14.36,0,0,1-.15-3c.15-1-3.27-16.16-3.27-16.16l-1-2.27L135,180l-9.08,1.13Z"/><path class="cls-42-4" d="M155.48,174.52,159,185.15l2,9.85v2s10.25-4.55,12.63-6.27L176,189l-3.63-13.65-1.85-6.79Z"/><path class="cls-42-2" d="M113.5,198.5s2,3,14,4,35-6,35-6S174.56,191,176,188.77l.47,1.73s-7.38,11-40.69,15c0,0-16.31,1-26.31-5l3-3Z"/><path class="cls-42-5" d="M119.94,181.44s2.56,10.06,3.56,13.06a27.84,27.84,0,0,1,.88,7.67s-7.88.33-11.88-4.67l-5-15a5.38,5.38,0,0,0-1-1.7A80.36,80.36,0,0,0,119.94,181.44Z"/><path class="cls-42-5" d="M140.5,181.5c0,.14,4.14,18,3.56,19.5,0,0,15.93-2.76,17.18-4.13,0,0-1.75-10.37-2.75-13.37s-3-9-3-9-7.82,3-10.4,3.47a54.09,54.09,0,0,0-5.58,1.51A6,6,0,0,1,140.5,181.5Z"/><path class="cls-42-6" d="M170.5,168.5S151,177,141,179l-1,.2a112,112,0,0,1-32.68,1.74q-1.88-.18-3.82-.44l6,20a51.86,51.86,0,0,0,26,5c15-1,35-7,41-15Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-43-2{fill:#f9ec31;}.cls-43-3{fill:#faaf40;}.cls-43-4,.cls-43-6{fill:none;}.cls-43-4,.cls-43-5,.cls-43-6{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-43-5{fill-opacity:0.4;}.cls-43-5,.cls-43-6{stroke-width:0.75px;}""",
|
||||
"""<g id="mouth-04"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M104,173s5.5,23.5,5.5,27.5c0,0,6,5,24,5s41-10,43-14c0,0-4-29-5-30,0,0-25,11-45,12-10.61.62-16.74.27-19.85-.08A20.06,20.06,0,0,1,104,173Z"/><path class="cls-43-2" d="M106.65,173.42s3.08,5.1,4.72,14.34,1,10.85,1,10.85S123,203,128,203a73,73,0,0,0,18-1.77,131.07,131.07,0,0,0,22.47-7.33l7.93-3.29-3.18-21.13-1.7-8s-26,11-45,12-20,0-20,0"/><path class="cls-43-3" d="M142.5,181.5V190l17.5-4,1,6v4.9l15-5.9-3-20.5C173.15,170.88,153.19,178.85,142.5,181.5Z"/><path class="cls-43-3" d="M142.4,190a38.22,38.22,0,0,0,2.1,11.5s-10.5,1.5-17,1S113,199,113,199l-2.5-15.5h11L123,191A122,122,0,0,0,142.4,190Z"/><path class="cls-43-3" d="M118,174l3.5,9c7.62.33,21-1.5,21-1.5s-1.13-7.27-2.5-9.5Z"/><path class="cls-43-4" d="M104,173s5.5,23.5,5.5,27.5c0,0,6,5,24,5s41-10,43-14c0,0-4-29-5-30,0,0-25,11-45,12-10.61.62-16.74.27-19.85-.08A20.06,20.06,0,0,1,104,173Z"/><path class="cls-43-5" d="M112.33,198.61S122,203,130,203s23.5-2.5,32.5-6.5,13.88-5.89,13.88-5.89l.12.89a31.45,31.45,0,0,1-8.77,5.75c-5.23,2.25-19.73,8.25-35.48,8.25s-22.75-5-22.75-5l2.83-1.89"/><path class="cls-43-5" d="M107.5,174.5a93.08,93.08,0,0,1,4,14,67.34,67.34,0,0,1,1,10l-3,2s-2.2-15.2-4.6-23.6l-.9-3.69,2.93.25Z"/><path class="cls-43-6" d="M124.61,202.46c.11-5-2.11-16-4.11-22s-3.42-6.66-3.42-6.66"/><path class="cls-43-6" d="M140.25,171.58s2.25,5.92,2.25,9.92v8s.33,10.83,2.67,11.92"/><path class="cls-43-6" d="M112,191s17,1,30-1,30-7,30-7l3-1"/><path class="cls-43-6" d="M142.5,181.5v8.42l17.51-3.7,1,8.28V194s-1-9-2-14-2.84-12.76-2.84-12.76"/><path class="cls-43-6" d="M176.5,190.5,173,170c-1.23,1.23-7.76,4-15.16,6.6-4.63,1.64-9.61,3.24-13.84,4.4-11,3-33.55,2.5-33.55,2.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-44-2{fill:none;}.cls-44-2,.cls-44-5{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-44-3{opacity:0.4;}.cls-44-4{fill:#461917;}.cls-44-5{fill-opacity:0.4;stroke-width:0.75px;}""",
|
||||
"""<g id="mouth-05"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M130.5,175.5v15a73.09,73.09,0,0,0,1,13s6,1,6,0-1-28-1-28C134.18,175.38,131.28,175.06,130.5,175.5Z"/><path class="cls-44-2" d="M130.5,175.5v15a73.09,73.09,0,0,0,1,13s6,1,6,0-1-28-1-28C134.18,175.38,131.28,175.06,130.5,175.5Z"/><path class="cls-44-3" d="M131.65,203a75.68,75.68,0,0,1-1.4-15.06,102.33,102.33,0,0,1,.48-12.27,3,3,0,0,1,1.25-.25,5.33,5.33,0,0,1,1,.1l.43.2h0s0,.16-.13.72a138.49,138.49,0,0,0-1,14.06,64.14,64.14,0,0,0,1,10.92Z"/><path d="M132,175.67h0a5,5,0,0,1,.87.08l.25.11a3.34,3.34,0,0,1-.09.52v.05a139.54,139.54,0,0,0-1,14.06,64.67,64.67,0,0,0,1,10.83l-1.16,1.16a77,77,0,0,1-1.3-14.56,107.4,107.4,0,0,1,.46-12.1,2.85,2.85,0,0,1,1-.17m0-.5a3.13,3.13,0,0,0-1.48.33s-.5,3-.5,12.44a72,72,0,0,0,1.5,15.56l2-2a62.68,62.68,0,0,1-1-11,138,138,0,0,1,1-14c.13-.56.15-.92,0-1l-.5-.23a5.57,5.57,0,0,0-1-.1Z"/><path class="cls-44-4" d="M137.19,201.56a5.59,5.59,0,0,0-2.48-.65,3.64,3.64,0,0,0-1.34.25,39.16,39.16,0,0,1-1.12-9c0-4.56,1.26-14.93,1.47-16.62l2.54.17.56,15.47Z"/><path d="M133.94,175.83,136,176l.56,15.25.35,9.94a5.61,5.61,0,0,0-2.21-.5,4,4,0,0,0-1.17.17,39,39,0,0,1-1-8.64c0-4.37,1.16-14.11,1.44-16.36m-.44-.53s-1.5,11.86-1.5,16.89a38.68,38.68,0,0,0,1.2,9.31,3.33,3.33,0,0,1,1.51-.35,5.56,5.56,0,0,1,2.74.85l-.38-10.8-.57-15.7-3-.2Z"/><path class="cls-44-5" d="M137.5,201.5v2l-5.7,0,1.7-2S134.5,199.5,137.5,201.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-45-2{fill-opacity:0.4;stroke-miterlimit:10;}.cls-45-2,.cls-45-3{stroke:#000;}.cls-45-3{fill:#461917;stroke-linecap:round;stroke-linejoin:round;stroke-width:0.5px;}""",
|
||||
"""<g id="mouth-06"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M149,179s-12,2-15,2-13.5,1.5-15.5,5.5-1,11.5,8,10.5,12-1.5,20-2.5,13-4,14-6,0-5-2-7S154.5,178.5,149,179Z"/><path class="cls-45-2" d="M149,179s-12,2-15,2-11.5.5-14.5,4.5c-4,4-2,11.5,7,11.5,8,0,12-1.5,20-2.5s13-4,14-6,0-5-2-7S154.5,178.5,149,179Z"/><path class="cls-45-3" d="M121.5,184.5s-3,4-1,6,4,3,12,2,13-3,18-3,10.83-2.26,9.42-6.13-5.75-5.12-8.58-4.5-12.7,1.53-16.26,2.08-6.7-.1-10.63,1.73S121.5,184.5,121.5,184.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-46-2{fill-opacity:0.4;}.cls-46-3{fill:none;}.cls-46-3,.cls-46-4{stroke:#000;stroke-linecap:round;stroke-linejoin:round;}.cls-46-4{fill:#461917;stroke-width:0.5px;}""",
|
||||
"""<g id="mouth-07"><g id="fill_color" data-name="fill color"><path id="background" class="cls-fill-1" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path id="background-2" data-name="background" class="cls-fill-1" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path id="background-3" data-name="background" class="cls-fill-1" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path id="background-4" data-name="background" class="cls-fill-1" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/></g><path class="cls-46-2" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path class="cls-46-3" d="M122,180s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,122,180Z"/><path class="cls-46-4" d="M124.5,180.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="125.85" y1="200.49" x2="127.5" y2="198.5"/><path class="cls-46-2" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path class="cls-46-3" d="M131,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,131,179Z"/><path class="cls-46-4" d="M133.5,179.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="134.85" y1="199.49" x2="136.5" y2="197.5"/><path class="cls-46-2" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path class="cls-46-3" d="M141,179s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,141,179Z"/><path class="cls-46-4" d="M143.5,179.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="144.85" y1="199.49" x2="146.5" y2="197.5"/><path class="cls-46-2" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/><path class="cls-46-3" d="M149,178s1,10,2,14,1.5,6.5,1.5,6.5,3,0,4-1-2-17-2-18v-2s-1.49,0-3,.11A12,12,0,0,0,149,178Z"/><path class="cls-46-4" d="M151.5,178.5s1,11,2,14a15.77,15.77,0,0,1,1,4s2.34-.11,2.17.44a31,31,0,0,0-.82-8.63,59,59,0,0,1-1.35-10.07v-.75h-3Z"/><line class="cls-46-4" x1="152.85" y1="198.49" x2="154.5" y2="196.5"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-47-2{fill-opacity:0.4;}.cls-47-2,.cls-47-3,.cls-47-4,.cls-47-5{stroke:#000;stroke-miterlimit:10;}.cls-47-3{fill:#f6921e;}.cls-47-4{fill:#f9ec31;stroke-width:0.75px;}.cls-47-5{fill:none;}""",
|
||||
"""<g id="mouth-08"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M148,175s-6.5.5-12.5,1.5a83.3,83.3,0,0,1-12,1v14c0,2,3,13,13,12s13-7,13-11-1-17-1-17h0S148.5,174.5,148,175Z"/><path class="cls-47-2" d="M148,175s-6.5.5-12.5,1.5a83.3,83.3,0,0,1-12,1v14c0,2,3,13,13,12s13-7,13-11-1-17-1-17h0S148.5,174.5,148,175Z"/><path class="cls-47-3" d="M129.59,177.21,130,191s1,7,7,8,12-.83,12.5-4.92-1-19.58-1-19.58l-10.88,1.67C135.5,176.5,129.59,177.21,129.59,177.21Z"/><path class="cls-47-4" d="M137.25,176.19l.22,22.88s-7-.57-7.39-7.64c-.58-4.93-.58-13.93-.58-13.93Z"/><path class="cls-47-5" d="M129.59,177.21,130,191s1,7,7,8,12-.83,12.5-4.92-1-19.58-1-19.58l-10.88,1.67C135.5,176.5,129.59,177.21,129.59,177.21Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-48-2{opacity:0.4;}.cls-48-3,.cls-48-4,.cls-48-5{fill:none;}.cls-48-3,.cls-48-4,.cls-48-5,.cls-48-6,.cls-48-7,.cls-48-8{stroke:#000;}.cls-48-3,.cls-48-5,.cls-48-7{stroke-miterlimit:10;}.cls-48-4,.cls-48-6,.cls-48-8{stroke-linecap:round;stroke-linejoin:round;}.cls-48-4,.cls-48-5{stroke-width:0.5px;}.cls-48-6{fill:#f6921e;stroke-width:1.2px;}.cls-48-7{fill:#f9ec31;}.cls-48-7,.cls-48-8{stroke-width:0.75px;}.cls-48-8{fill:#f05a27;}""",
|
||||
"""<g id="mouth-09"><path id="fill_color" data-name="fill color" class="cls-fill-1" d="M109.5,197.5c0,4,1,5,1,5h34c10,0,21-1,21-1v-23l-1-4h-2c-2,0-54.5.5-54.5.5v11Z"/><path class="cls-48-2" d="M163.5,174.5v25s-52,1.5-53.75,1l.61,1.79,28.9.21s19.65-.79,21.7-.65A9,9,0,0,0,165,201l.41-22.84S164,174,163.5,174.5Z"/><path class="cls-48-3" d="M109.5,197.5c0,4,1,5,1,5h34c10,0,21-1,21-1v-23l-1-4h-2c-2,0-54.5.5-54.5.5v11Z"/><path class="cls-48-4" d="M113.5,200.5h27c10,0,23-1,23-1v-24"/><line class="cls-48-5" x1="163.5" y1="199.5" x2="165" y2="201"/><path class="cls-48-4" d="M162,177v12"/><path class="cls-48-6" d="M112.5,177.5h27c9,0,20-1,20-1v20l-54,1a34.21,34.21,0,0,1,2-11A44.27,44.27,0,0,1,112.5,177.5Z"/><path class="cls-48-7" d="M159,176.5s-3,7.5-4,11.5a87,87,0,0,0-1.59,8.61l-12.91.19s-1-2.3,1-7.3,5-12,5-12S157.5,177,159,176.5Z"/><path class="cls-48-7" d="M128.5,196.89h-11a19.28,19.28,0,0,1,2-11.22c3-6.12,3.75-8.16,3.75-8.16h11.56s-1.31,3.06-2.31,6.12-4,6.12-4,10.2Z"/><path class="cls-48-8" d="M135.13,177.5H146.5l-5.43,13.25a8.08,8.08,0,0,0-.82,4c.25,1.89,0,2.25,0,2.25H128.5a23.82,23.82,0,0,1,.92-7.35c1.08-3.08,2.1-3,3.59-7.58S135.13,177.5,135.13,177.5Z"/></g>"""
|
||||
),
|
||||
Part(
|
||||
""".cls-49-2{fill:none;}.cls-49-2,.cls-49-3,.cls-49-5{stroke:#000;}.cls-49-2,.cls-49-3{stroke-linecap:round;stroke-linejoin:round;}.cls-49-3{opacity:0.1;}.cls-49-4{opacity:0.4;}.cls-49-5{fill-opacity:0.4;stroke-miterlimit:10;stroke-width:0.75px;}""",
|
||||
"""<path id="fill_color" data-name="fill color" class="cls-fill-1" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-2" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-3" d="M105.5,180.5s18,3,32,0,32-11,32-11h1.33a1.13,1.13,0,0,1,.85.65l4.82,17.35v3a3.73,3.73,0,0,1-.94,1.67c-2.53,1.85-9.8,6.88-17.06,9.3a72.67,72.67,0,0,1-25,4c-8.1,0-21.07-4.05-23.57-4.86l-.43-.14-5-19A1,1,0,0,1,105.5,180.5Z"/><path class="cls-49-4" d="M108.33,195.83c0-.11-2.8-10.45-3.4-12.94a6.79,6.79,0,0,1-.3-2,2.1,2.1,0,0,1,.46,0,3,3,0,0,1,1.77.84c.32.85,3,8.16,4,11.88a31.56,31.56,0,0,1,1,5.64l-2.35.78Z"/><path d="M105.08,181.25h.09a2.55,2.55,0,0,1,1.36.68c.43,1.16,3,8.16,3.91,11.75a33.25,33.25,0,0,1,.95,5.29l-1.7.57-1-3.8c0-.1-2.8-10.45-3.4-12.93a13.31,13.31,0,0,1-.31-1.55h.1m0-.75c-.92,0-1.11,0-.52,2.48S108,195.93,108,195.93l1.2,4.57,3-1a28,28,0,0,0-1-6c-1-4-4-12-4-12a3.49,3.49,0,0,0-2-1Z"/><path class="cls-49-5" d="M111.5,199.5s17,6,36,2,29-11.87,29-11.87v.88l-.36.94-.44.64L174,193.33l-7,4.33L159.9,201l-10.68,3-4.63.79-5.34.51-5.75.16-5.06-.39L121,203.7,114.57,202l-5.07-1.54-.12-.67Z"/><path class="cls-49-2" d="M116.51,200.51c1-3,3-11,3-11s4,2,13,2,27-7,27-7,7,6,8,10"/>"""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.DefaultAlpha
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
robot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
|
||||
onState: ((AsyncImagePainter.State) -> Unit)? = null,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(LocalContext.current, robot),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RobohashFallbackAsyncImage(
|
||||
robot: String,
|
||||
model: String?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
|
||||
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImageProxy(
|
||||
robot: String,
|
||||
model: ResizeImage,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
alignment: Alignment = Alignment.Center,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
alpha: Float = DefaultAlpha,
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
if (model.url == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
} else {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = robot,
|
||||
model = model.proxyUrl(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,14 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
if (it.url == url) {
|
||||
UrlPreviewState.Loaded(it)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
} ?: UrlPreviewState.Loading
|
||||
var context = LocalContext.current
|
||||
// val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
// if (it.url == url) {
|
||||
// UrlPreviewState.Loaded(it)
|
||||
// } else {
|
||||
// UrlPreviewState.Empty
|
||||
// }
|
||||
// } ?: UrlPreviewState.Loading
|
||||
val context = LocalContext.current
|
||||
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.gestures.forEachGesture
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -32,17 +32,15 @@ fun ZoomableAsyncImage(imageUrl: String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
forEachGesture {
|
||||
awaitPointerEventScope {
|
||||
awaitFirstDown()
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
scale *= event.calculateZoom()
|
||||
val offset = event.calculatePan()
|
||||
offsetX += offset.x
|
||||
offsetY += offset.y
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
scale *= event.calculateZoom()
|
||||
val offset = event.calculatePan()
|
||||
offsetX += offset.x
|
||||
offsetY += offset.y
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -11,6 +11,7 @@ object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed() = LocalCache.notes.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) &&
|
||||
it.replyTo.isNullOrEmpty()
|
||||
@@ -19,9 +20,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
// 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)
|
||||
(it.author?.pubkeyHex !in account.followingKeySet())
|
||||
}
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.toList()
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
||||
return LocalCache.notes.values
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent) &&
|
||||
it.author in user.follows &&
|
||||
it.author?.pubkeyHex in user.cachedFollowingKeySet() &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !HomeNewThreadFeedFilter.account.isHidden(it) } ?: true &&
|
||||
!it.isNewThread()
|
||||
|
||||
@@ -16,7 +16,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
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 user.cachedFollowingKeySet() &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
it.isNewThread()
|
||||
@@ -25,7 +25,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 user.cachedFollowingKeySet() &&
|
||||
// && 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()?.map {
|
||||
LocalCache.getOrCreateUser(it)
|
||||
}?.toSet()
|
||||
?.filter { account.isAcceptable(it) }
|
||||
?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
|
||||
@Composable
|
||||
fun AccountSwitchBottomSheet(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
val accounts = LocalPreferences.allSavedAccounts()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(modifier = Modifier.verticalScroll(scrollState)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
accounts.forEach { acc ->
|
||||
val current = accountUser.pubkeyNpub() == acc.npub
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp, 16.dp)
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = acc.npub.bechToBytes("npub").toHex(),
|
||||
model = ResizeImage(acc.profilePicture, 55.dp),
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
if (acc.hasPrivKey) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = stringResource(R.string.account_switch_has_private_key),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = stringResource(R.string.account_switch_pubkey_only),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val npubShortHex = acc.npub.toShortenHex()
|
||||
|
||||
if (acc.displayName != null && acc.displayName != npubShortHex) {
|
||||
Text(acc.displayName)
|
||||
}
|
||||
|
||||
Text(npubShortHex)
|
||||
}
|
||||
Column(modifier = Modifier.width(32.dp)) {
|
||||
if (current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonChecked,
|
||||
contentDescription = stringResource(R.string.account_switch_active_account),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { accountStateViewModel.logOff(acc.npub) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Logout,
|
||||
contentDescription = stringResource(R.string.log_out),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { popupExpanded = true }) {
|
||||
Text(stringResource(R.string.account_switch_add_account_btn))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (popupExpanded) {
|
||||
Dialog(
|
||||
onDismissRequest = { popupExpanded = false },
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box {
|
||||
LoginPage(accountStateViewModel, isFirstLogin = false)
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.account_switch_add_account_dialog_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { popupExpanded = false }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = stringResource(R.string.back),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
},
|
||||
backgroundColor = Color.Transparent,
|
||||
elevation = 0.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ 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.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -84,8 +83,8 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
@Composable
|
||||
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
|
||||
val currentRoute = currentRoute(navController)
|
||||
val currentRouteBase = currentRoute?.substringBefore("?")
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val isKeyboardOpen by keyboardAsState()
|
||||
|
||||
if (isKeyboardOpen == Keyboard.Closed) {
|
||||
@@ -99,13 +98,15 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
backgroundColor = MaterialTheme.colors.background
|
||||
) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
val selected = currentRouteBase == item.base
|
||||
|
||||
BottomNavigationItem(
|
||||
icon = { NotifiableIcon(item, currentRoute, accountViewModel) },
|
||||
selected = currentRoute == item.route,
|
||||
icon = { NotifiableIcon(item, selected, accountViewModel) },
|
||||
selected = selected,
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
if (currentRoute != item.route) {
|
||||
navController.navigate(item.route) {
|
||||
if (currentRouteBase != item.base) {
|
||||
navController.navigate(item.base) {
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start)
|
||||
restoreState = true
|
||||
@@ -114,8 +115,8 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
restoreState = true
|
||||
}
|
||||
} else {
|
||||
// TODO: Make it scrool to the top
|
||||
navController.navigate(item.route) {
|
||||
val route = currentRoute.replace("{scrollToTop}", "true")
|
||||
navController.navigate(route) {
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start) { inclusive = item.route == Route.Home.route }
|
||||
restoreState = true
|
||||
@@ -135,13 +136,13 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel: AccountViewModel) {
|
||||
Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) {
|
||||
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
||||
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = item.icon),
|
||||
null,
|
||||
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
|
||||
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
|
||||
painter = painterResource(id = route.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(if ("Home" == route.base) 24.dp else 20.dp),
|
||||
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
|
||||
)
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
@@ -156,17 +157,15 @@ private fun NotifiableIcon(item: Route, currentRoute: String?, accountViewModel:
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
withContext(Dispatchers.IO) {
|
||||
hasNewItems = item.hasNewItems(account, notif.cache, context)
|
||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
hasNewItems = item.hasNewItems(account, notif.cache, context)
|
||||
hasNewItems = route.hasNewItems(account, notif.cache)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,117 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
nextPage: String? = null
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
|
||||
val globalFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel()
|
||||
val homeRepliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
|
||||
val homePagerState = rememberPagerState()
|
||||
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Routes.forEach {
|
||||
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController))
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
SearchScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
feedViewModel = globalFeedViewModel,
|
||||
navController = navController,
|
||||
scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Home.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
HomeScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = homeRepliesFeedViewModel,
|
||||
pagerState = homePagerState,
|
||||
scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) })
|
||||
composable(Route.Notification.route, content = { NotificationScreen(accountViewModel, navController) })
|
||||
composable(Route.Filters.route, content = { FiltersScreen(accountViewModel, navController) })
|
||||
|
||||
Route.Profile.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ProfileScreen(
|
||||
userId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Note.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ThreadScreen(
|
||||
noteId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Room.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChatroomScreen(
|
||||
userId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Channel.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
ChannelScreen(
|
||||
channelId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -42,7 +41,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -59,8 +57,8 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -73,6 +71,7 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(coil.annotation.ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
@@ -88,7 +87,6 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
@@ -101,7 +99,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
Column() {
|
||||
TopAppBar(
|
||||
elevation = 0.dp,
|
||||
backgroundColor = Color(0xFFFFFF),
|
||||
backgroundColor = MaterialTheme.colors.surface,
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -197,11 +195,9 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
},
|
||||
modifier = Modifier
|
||||
) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
|
||||
@@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
@@ -33,9 +35,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -46,22 +46,21 @@ import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
navController: NavHostController,
|
||||
scaffoldState: ScaffoldState,
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
sheetState: ModalBottomSheetState,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
@@ -88,10 +87,10 @@ fun DrawerContent(
|
||||
account.userProfile(),
|
||||
navController,
|
||||
scaffoldState,
|
||||
sheetState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F),
|
||||
accountStateViewModel,
|
||||
.weight(1f),
|
||||
account
|
||||
)
|
||||
|
||||
@@ -110,11 +109,9 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
|
||||
val accountUserFollows = accountUserFollowsState?.user ?: return
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Box {
|
||||
val banner = accountUser.info?.banner
|
||||
if (banner != null && banner.isNotBlank()) {
|
||||
if (!banner.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = banner,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
@@ -135,12 +132,10 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 100.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, accountUser.pubkeyHex)),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp)
|
||||
@@ -202,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))
|
||||
}
|
||||
}
|
||||
@@ -214,15 +209,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ListContent(
|
||||
accountUser: User?,
|
||||
navController: NavHostController,
|
||||
scaffoldState: ScaffoldState,
|
||||
sheetState: ModalBottomSheetState,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountStateViewModel,
|
||||
account: Account
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = modifier.fillMaxHeight()) {
|
||||
@@ -260,10 +257,10 @@ fun ListContent(
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
IconRow(
|
||||
stringResource(R.string.log_out),
|
||||
R.drawable.ic_logout,
|
||||
MaterialTheme.colors.onBackground,
|
||||
onClick = { accountViewModel.logOff() }
|
||||
title = stringResource(R.string.drawer_accounts),
|
||||
icon = R.drawable.manage_accounts,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onClick = { coroutineScope.launch { sheetState.show() } }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -16,133 +13,110 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
|
||||
sealed class Route(
|
||||
val route: String,
|
||||
val icon: Int,
|
||||
val hasNewItems: (Account, NotificationCache, Context) -> Boolean = { _, _, _ -> false },
|
||||
val arguments: List<NamedNavArgument> = emptyList(),
|
||||
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
|
||||
val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
|
||||
val arguments: List<NamedNavArgument> = emptyList()
|
||||
) {
|
||||
val base: String
|
||||
get() = route.substringBefore("?")
|
||||
|
||||
object Home : Route(
|
||||
"Home",
|
||||
R.drawable.ic_home,
|
||||
hasNewItems = { acc, cache, ctx -> homeHasNewItems(acc, cache, ctx) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
|
||||
route = "Home?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_home,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
|
||||
hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
|
||||
)
|
||||
|
||||
object Search : Route(
|
||||
"Search",
|
||||
R.drawable.ic_globe,
|
||||
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) } }
|
||||
route = "Search?scrollToTop={scrollToTop}",
|
||||
icon = R.drawable.ic_globe,
|
||||
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
|
||||
)
|
||||
|
||||
object Notification : Route(
|
||||
"Notification",
|
||||
R.drawable.ic_notifications,
|
||||
hasNewItems = { acc, cache, ctx -> notificationHasNewItems(acc, cache, ctx) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) } }
|
||||
route = "Notification",
|
||||
icon = R.drawable.ic_notifications,
|
||||
hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
|
||||
)
|
||||
|
||||
object Message : Route(
|
||||
"Message",
|
||||
R.drawable.ic_dm,
|
||||
hasNewItems = { acc, cache, ctx -> messagesHasNewItems(acc, cache, ctx) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) } }
|
||||
route = "Message",
|
||||
icon = R.drawable.ic_dm,
|
||||
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
|
||||
)
|
||||
|
||||
object Filters : Route(
|
||||
"Filters",
|
||||
R.drawable.ic_security,
|
||||
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) } }
|
||||
route = "Filters",
|
||||
icon = R.drawable.ic_security
|
||||
)
|
||||
|
||||
object Profile : Route(
|
||||
"User/{id}",
|
||||
R.drawable.ic_profile,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) } }
|
||||
route = "User/{id}",
|
||||
icon = R.drawable.ic_profile,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Note : Route(
|
||||
"Note/{id}",
|
||||
R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) } }
|
||||
route = "Note/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Room : Route(
|
||||
"Room/{id}",
|
||||
R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) } }
|
||||
route = "Room/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Channel : Route(
|
||||
"Channel/{id}",
|
||||
R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType }),
|
||||
buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) } }
|
||||
route = "Channel/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
}
|
||||
|
||||
val Routes = listOf(
|
||||
// bottom
|
||||
Route.Home,
|
||||
Route.Message,
|
||||
Route.Search,
|
||||
Route.Notification,
|
||||
|
||||
// drawer
|
||||
Route.Profile,
|
||||
Route.Note,
|
||||
Route.Room,
|
||||
Route.Channel,
|
||||
Route.Filters
|
||||
)
|
||||
|
||||
// **
|
||||
// * Functions below only exist because we have not broken the datasource classes into backend and frontend.
|
||||
// **
|
||||
@Composable
|
||||
public fun currentRoute(navController: NavHostController): String? {
|
||||
fun currentRoute(navController: NavHostController): String? {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
return navBackStackEntry?.destination?.route
|
||||
}
|
||||
|
||||
private fun homeHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
|
||||
val lastTime = cache.load("HomeFollows", context)
|
||||
private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
val lastTime = cache.load("HomeFollows")
|
||||
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
|
||||
return (HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime
|
||||
return (
|
||||
HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
||||
?: 0
|
||||
) > lastTime
|
||||
}
|
||||
|
||||
private fun notificationHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
|
||||
val lastTime = cache.load("Notification", context)
|
||||
private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
val lastTime = cache.load("Notification")
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
|
||||
return (NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt() ?: 0) > lastTime
|
||||
return (
|
||||
NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
|
||||
?: 0
|
||||
) > lastTime
|
||||
}
|
||||
|
||||
private fun messagesHasNewItems(account: Account, cache: NotificationCache, context: Context): Boolean {
|
||||
private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
|
||||
ChatroomListKnownFeedFilter.account = account
|
||||
|
||||
val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
|
||||
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
|
||||
} ?: return false
|
||||
|
||||
val lastTime = cache.load("Room/${note.author?.pubkeyHex}", context)
|
||||
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
|
||||
|
||||
return (note.createdAt() ?: 0) > lastTime
|
||||
}
|
||||
|
||||
@@ -42,13 +42,10 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by likeSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
@@ -61,13 +58,13 @@ fun BadgeCompose(likeSetCard: BadgeCard, modifier: Modifier = Modifier, isInnerN
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead, context)
|
||||
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt(), context)
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -44,8 +43,6 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -56,13 +53,13 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
|
||||
LaunchedEffect(key1 = boostSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = boostSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
isNew = boostSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
|
||||
@@ -27,8 +27,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -43,12 +41,13 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
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.ui.components.AsyncImageProxy
|
||||
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
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -65,8 +64,6 @@ fun ChatroomCompose(
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (note.channel() != null) {
|
||||
@@ -85,27 +82,22 @@ fun ChatroomCompose(
|
||||
} else {
|
||||
noteEvent?.content()
|
||||
}
|
||||
channel?.let { channel ->
|
||||
channel?.let { chan ->
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
note.createdAt()?.let {
|
||||
note.createdAt()?.let { timestamp ->
|
||||
hasNewMessages =
|
||||
it > notificationCache.cache.load("Channel/${channel.idHex}", context)
|
||||
timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelPicture = channel.profilePicture(),
|
||||
channelPicturePlaceholder = BitmapPainter(
|
||||
RoboHashCache.get(
|
||||
context,
|
||||
channel.idHex
|
||||
)
|
||||
),
|
||||
channelTitle = {
|
||||
channelIdHex = chan.idHex,
|
||||
channelPicture = chan.profilePicture(),
|
||||
channelTitle = { modifier ->
|
||||
Text(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
@@ -113,7 +105,7 @@ fun ChatroomCompose(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
) {
|
||||
append(channel.info.name)
|
||||
append(chan.info.name)
|
||||
}
|
||||
|
||||
withStyle(
|
||||
@@ -126,18 +118,21 @@ fun ChatroomCompose(
|
||||
}
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = it,
|
||||
modifier = modifier,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
},
|
||||
channelLastTime = note.createdAt(),
|
||||
channelLastContent = "${author?.toBestDisplayName()}: " + description,
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { navController.navigate("Channel/${channel.idHex}") }
|
||||
onClick = { navController.navigate("Channel/${chan.idHex}") }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val replyAuthorBase = note.mentions?.first()
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
@@ -156,8 +151,7 @@ fun ChatroomCompose(
|
||||
withContext(Dispatchers.IO) {
|
||||
noteEvent?.let {
|
||||
hasNewMessages = it.createdAt() > notificationCache.cache.load(
|
||||
"Room/${userToComposeOn.pubkeyHex}",
|
||||
context
|
||||
"Room/${userToComposeOn.pubkeyHex}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -183,8 +177,8 @@ fun ChatroomCompose(
|
||||
|
||||
@Composable
|
||||
fun ChannelName(
|
||||
channelIdHex: String,
|
||||
channelPicture: String?,
|
||||
channelPicturePlaceholder: Painter?,
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
@@ -193,11 +187,9 @@ fun ChannelName(
|
||||
) {
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channelIdHex,
|
||||
model = ResizeImage(channelPicture, 55.dp),
|
||||
placeholder = channelPicturePlaceholder,
|
||||
fallback = channelPicturePlaceholder,
|
||||
error = channelPicturePlaceholder,
|
||||
contentDescription = stringResource(R.string.channel_image),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
|
||||
@@ -40,7 +40,6 @@ import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -51,17 +50,16 @@ import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -134,11 +132,11 @@ fun ChatroomMessageCompose(
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it, context)
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt, context)
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
isNew = createdAt > lastTime
|
||||
}
|
||||
}
|
||||
@@ -149,12 +147,14 @@ fun ChatroomMessageCompose(
|
||||
val modif = if (innerQuote) {
|
||||
Modifier.padding(top = 10.dp, end = 5.dp)
|
||||
} else {
|
||||
Modifier.fillMaxWidth(1f).padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
Modifier
|
||||
.fillMaxWidth(1f)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -182,9 +182,11 @@ fun ChatroomMessageCompose(
|
||||
var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged {
|
||||
bubbleSize = it
|
||||
}
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
bubbleSize = it
|
||||
}
|
||||
) {
|
||||
val authorState by note.author!!.live().metadata.observeAsState()
|
||||
val author = authorState?.user!!
|
||||
@@ -195,28 +197,26 @@ fun ChatroomMessageCompose(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = ResizeImage(author.profilePicture(), 25.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(context, author.pubkeyHex)),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(25.dp)
|
||||
.height(25.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${author?.toBestDisplayName()}",
|
||||
" ${author.toBestDisplayName()}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
author?.let {
|
||||
author.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
@@ -225,9 +225,9 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
|
||||
val replyTo = note.replyTo
|
||||
if (!innerQuote && replyTo != null && replyTo.isNotEmpty()) {
|
||||
if (!innerQuote && !replyTo.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo.toSet().mapIndexed { index, note ->
|
||||
replyTo.toSet().mapIndexed { _, note ->
|
||||
if (note.event != null) {
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
@@ -307,11 +307,16 @@ fun ChatroomMessageCompose(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.padding(top = 5.dp).then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(bubbleSize.width.toDp(), availableBubbleSize.width.toDp())
|
||||
}
|
||||
)
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp)
|
||||
.then(
|
||||
with(LocalDensity.current) {
|
||||
Modifier.widthIn(
|
||||
bubbleSize.width.toDp(),
|
||||
availableBubbleSize.width.toDp()
|
||||
)
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row() {
|
||||
Text(
|
||||
@@ -355,7 +360,6 @@ private fun RelayBadges(baseNote: Note) {
|
||||
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
FlowRow(Modifier.padding(start = 10.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
@@ -365,18 +369,16 @@ private fun RelayBadges(baseNote: Note) {
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
model = "https://$url/favicon.ico",
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
contentDescription = stringResource(id = R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://" + url) })
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -37,15 +36,13 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by likeSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -56,13 +53,13 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
|
||||
@@ -21,7 +21,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -39,11 +38,6 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
val noteState by messageSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -55,13 +49,13 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
LaunchedEffect(key1 = messageSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew =
|
||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead, context)
|
||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt(), context)
|
||||
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -41,15 +40,13 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by multiSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -60,13 +57,13 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, modifier: Modifier = Modifier, r
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
|
||||
} else {
|
||||
MaterialTheme.colors.background
|
||||
|
||||
@@ -19,7 +19,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -38,7 +37,6 @@ import coil.compose.AsyncImage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -53,9 +51,11 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
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
|
||||
@@ -124,18 +124,18 @@ fun NoteCompose(
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
withContext(Dispatchers.IO) {
|
||||
routeForLastRead?.let {
|
||||
val lastTime = NotificationCache.load(it, context)
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(it, createdAt, context)
|
||||
NotificationCache.markAsRead(it, createdAt)
|
||||
isNew = createdAt > lastTime
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor = if (isNew) {
|
||||
val backgroundColor = if (isNew) {
|
||||
val newColor = MaterialTheme.colors.primary.copy(0.12f)
|
||||
if (parentBackgroundColor != null) {
|
||||
newColor.compositeOver(parentBackgroundColor)
|
||||
@@ -217,11 +217,9 @@ fun NoteCompose(
|
||||
.height(30.dp)
|
||||
.align(Alignment.BottomEnd)
|
||||
) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channel.idHex,
|
||||
model = ResizeImage(channel.profilePicture(), 30.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
contentDescription = stringResource(R.string.group_picture),
|
||||
modifier = Modifier
|
||||
.width(30.dp)
|
||||
@@ -296,7 +294,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()
|
||||
.map { LocalCache.getOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowing(it) }
|
||||
|
||||
val replyingDirectlyTo = note.replyTo?.lastOrNull()
|
||||
if (replyingDirectlyTo != null && unPackReply) {
|
||||
NoteCompose(
|
||||
@@ -318,10 +321,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()
|
||||
.map { LocalCache.getOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.userProfile().isFollowing(it) }
|
||||
|
||||
note.channel()?.let {
|
||||
ReplyInformationChannel(note.replyTo, sortedMentions, it, navController)
|
||||
@@ -344,7 +350,7 @@ fun NoteCompose(
|
||||
// Reposts have trash in their contents.
|
||||
if (noteEvent is ReactionEvent) {
|
||||
val refactorReactionText =
|
||||
if (noteEvent.content == "+") "❤" else noteEvent.content ?: " "
|
||||
if (noteEvent.content == "+") "❤" else noteEvent.content
|
||||
|
||||
Text(
|
||||
text = refactorReactionText
|
||||
@@ -359,7 +365,6 @@ fun NoteCompose(
|
||||
ReportEvent.ReportType.SPAM -> stringResource(R.string.spam)
|
||||
ReportEvent.ReportType.IMPERSONATION -> stringResource(R.string.impersonation)
|
||||
ReportEvent.ReportType.ILLEGAL -> stringResource(R.string.illegal_behavior)
|
||||
else -> stringResource(R.string.unknown)
|
||||
}
|
||||
}.toSet().joinToString(", ")
|
||||
|
||||
@@ -384,14 +389,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 {
|
||||
@@ -592,7 +599,6 @@ private fun RelayBadges(baseNote: Note) {
|
||||
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
@@ -602,11 +608,9 @@ private fun RelayBadges(baseNote: Note) {
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
model = "https://$url/favicon.ico",
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, url)),
|
||||
contentDescription = stringResource(R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
@@ -660,7 +664,7 @@ fun NoteAuthorPicture(
|
||||
baseNote: Note,
|
||||
baseUserAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: ((User) -> Unit)? = null
|
||||
) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
@@ -668,24 +672,22 @@ fun NoteAuthorPicture(
|
||||
|
||||
val author = note.author
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)
|
||||
) {
|
||||
if (author == null) {
|
||||
Image(
|
||||
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
|
||||
RobohashAsyncImage(
|
||||
robot = "authornotfound",
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = pictureModifier
|
||||
modifier = modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
} else {
|
||||
UserPicture(author, baseUserAccount, size, pictureModifier, onClick)
|
||||
UserPicture(author, baseUserAccount, size, modifier, onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,27 +711,23 @@ fun UserPicture(
|
||||
baseUser: User,
|
||||
baseUserAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: ((User) -> Unit)? = null,
|
||||
onLongClick: ((User) -> Unit)? = null
|
||||
) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)
|
||||
) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = user.pubkeyHex,
|
||||
model = ResizeImage(user.profilePicture(), size),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
modifier = pictureModifier
|
||||
modifier = modifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
@@ -799,7 +797,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_text))
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}" ?: "")); onDismiss() }) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")); onDismiss() }) {
|
||||
Text(stringResource(R.string.copy_user_pubkey))
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) {
|
||||
@@ -829,17 +827,14 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
if (note.author == accountViewModel.accountLiveData.value?.account?.userProfile()) {
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) {
|
||||
Text("Request Deletion")
|
||||
Text(stringResource(R.string.request_deletion))
|
||||
}
|
||||
}
|
||||
if (note.author != accountViewModel.accountLiveData.value?.account?.userProfile()) {
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
note.author?.let {
|
||||
accountViewModel.hide(
|
||||
it,
|
||||
appContext
|
||||
)
|
||||
accountViewModel.hide(it)
|
||||
}; onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.block_hide_user))
|
||||
@@ -847,35 +842,35 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.SPAM)
|
||||
note.author?.let { accountViewModel.hide(it, appContext) }
|
||||
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, appContext) }
|
||||
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, appContext) }
|
||||
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, appContext) }
|
||||
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, appContext) }
|
||||
note.author?.let { accountViewModel.hide(it) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_illegal_behaviour))
|
||||
|
||||
@@ -124,7 +124,7 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) {
|
||||
clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}" ?: ""))
|
||||
clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}"))
|
||||
showToast(R.string.copied_user_id_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
@@ -39,12 +39,10 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -280,7 +278,7 @@ fun LikeReaction(
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (reactedNote?.isReactedBy(accountViewModel.userProfile()) == true) {
|
||||
if (reactedNote.isReactedBy(accountViewModel.userProfile())) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
@@ -298,7 +296,7 @@ fun LikeReaction(
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(reactedNote?.reactions?.size)}",
|
||||
" ${showCount(reactedNote.reactions.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = textModifier
|
||||
@@ -590,15 +588,13 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: UpdateZapAmountViewModel = viewModel()
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
// val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -34,7 +33,6 @@ import java.time.format.DateTimeFormatter
|
||||
fun RelayCompose(
|
||||
relay: RelayInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
onAddRelay: () -> Unit,
|
||||
onRemoveRelay: () -> Unit
|
||||
) {
|
||||
@@ -48,8 +46,6 @@ fun RelayCompose(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
) {
|
||||
// UserPicture(user, navController, account.userProfile(), 55.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -33,7 +32,6 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
val userState by account.userProfile().live().follows.observeAsState()
|
||||
val userFollows = userState?.user ?: return
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
@@ -58,8 +56,8 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
UsernameDisplay(baseUser)
|
||||
}
|
||||
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val baseUserState by baseUser.live().metadata.observeAsState()
|
||||
val user = baseUserState?.user ?: return
|
||||
|
||||
Text(
|
||||
user.info?.about ?: "",
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -54,7 +53,6 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
|
||||
|
||||
val baseAuthor = noteZapRequest.author
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (baseAuthor == null) {
|
||||
@@ -83,8 +81,8 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
|
||||
UsernameDisplay(baseAuthor)
|
||||
}
|
||||
|
||||
val userState by baseAuthor.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val baseAuthorState by baseAuthor.live().metadata.observeAsState()
|
||||
val user = baseAuthorState?.user ?: return
|
||||
|
||||
Text(
|
||||
user.info?.about ?: "",
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
@@ -39,15 +38,13 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by zapSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -58,9 +55,9 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, modifier: Modifier = Modifier, isInner
|
||||
|
||||
LaunchedEffect(key1 = zapSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +1,59 @@
|
||||
package com.vitorpamplona.amethyst.ui.qrcode
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Composable
|
||||
fun QrCodeScanner(onScan: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
|
||||
val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { granted ->
|
||||
hasCameraPermission = granted
|
||||
}
|
||||
)
|
||||
|
||||
val analyzer = QRCodeAnalyzer { result ->
|
||||
result?.let {
|
||||
try {
|
||||
val nip19 = Nip19.uriToRoute(it)
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (startingPage != null) {
|
||||
onScan(startingPage)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// QR can be anythign. do not throw errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = true) {
|
||||
launcher.launch(Manifest.permission.CAMERA)
|
||||
onDispose() {
|
||||
cameraProviderFuture.get().unbindAll()
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (hasCameraPermission) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val previewView = PreviewView(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
return@AndroidView previewView
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bindPreview(
|
||||
analyzer: ImageAnalysis.Analyzer,
|
||||
previewView: PreviewView,
|
||||
cameraExecutor: ExecutorService,
|
||||
cameraProvider: ProcessCameraProvider,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
) {
|
||||
val preview = Preview.Builder().build()
|
||||
|
||||
val selector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
imageAnalysis.setAnalyzer(
|
||||
cameraExecutor,
|
||||
analyzer
|
||||
)
|
||||
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
imageAnalysis,
|
||||
preview
|
||||
)
|
||||
}
|
||||
|
||||
class QRCodeAnalyzer(
|
||||
private val onQrCodeScanned: (result: String?) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
|
||||
private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
|
||||
|
||||
fun scanBarcodes(inputImage: InputImage) {
|
||||
BarcodeScanning.getClient(scanningOptions).process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (barcodes.isNotEmpty()) {
|
||||
onQrCodeScanned(barcodes[0].displayValue)
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
imageProxy.image?.let { image ->
|
||||
val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees)
|
||||
scanBarcodes(inputImage)
|
||||
}
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
package com.vitorpamplona.amethyst.ui.qrcode
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.zxing.client.android.Intents
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
@Composable
|
||||
fun QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val parseQrResult = { it: String ->
|
||||
try {
|
||||
val nip19 = Nip19.uriToRoute(it)
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (startingPage != null) {
|
||||
onScan(startingPage)
|
||||
} else {
|
||||
onScan(null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// QR can be anything, do not throw errors.
|
||||
onScan(null)
|
||||
}
|
||||
}
|
||||
|
||||
val qrLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) {
|
||||
if (it.contents != null) {
|
||||
parseQrResult(it.contents)
|
||||
} else {
|
||||
onScan(null)
|
||||
}
|
||||
}
|
||||
|
||||
val scanOptions = ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt(stringResource(id = R.string.point_to_the_qr_code))
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
qrLauncher.launch(scanOptions)
|
||||
onDispose { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +1,150 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
|
||||
|
||||
@Composable
|
||||
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
var presenting by remember { mutableStateOf(true) }
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onClose)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (presenting) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
AsyncImageProxy(
|
||||
model = ResizeImage(user.profilePicture(), 100.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, user.pubkeyHex)),
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(" @${user.bestUsername()}", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp)
|
||||
) {
|
||||
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { presenting = false },
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.scan_qr))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(R.string.point_to_the_qr_code),
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 25.sp
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||
) {
|
||||
QrCodeScanner(onScan)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { presenting = true },
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_qr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
|
||||
|
||||
@Composable
|
||||
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
var presenting by remember { mutableStateOf(true) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onClose)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 10.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (presenting) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = user.pubkeyHex,
|
||||
model = ResizeImage(user.profilePicture(), 100.dp),
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(" @${user.bestUsername()}", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 35.dp, vertical = 10.dp)
|
||||
) {
|
||||
QrCodeDrawer("nostr:${user.pubkeyNpub()}")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = { presenting = false },
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.scan_qr))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
QrCodeScanner {
|
||||
if (it.isNullOrEmpty()) {
|
||||
presenting = true
|
||||
} else {
|
||||
onScan(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {
|
||||
@@ -17,7 +18,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
|
||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is AccountState.LoggedOff -> {
|
||||
LoginPage(accountStateViewModel)
|
||||
LoginPage(accountStateViewModel, isFirstLogin = true)
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -17,7 +18,7 @@ import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class AccountStateViewModel(private val localPreferences: LocalPreferences) : ViewModel() {
|
||||
class AccountStateViewModel() : ViewModel() {
|
||||
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
@@ -26,10 +27,14 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
||||
|
||||
// Keeps it in the the UI thread to void blinking the login page.
|
||||
// viewModelScope.launch(Dispatchers.IO) {
|
||||
localPreferences.loadFromEncryptedStorage()?.let {
|
||||
tryLoginExistingAccount()
|
||||
// }
|
||||
}
|
||||
|
||||
private fun tryLoginExistingAccount() {
|
||||
LocalPreferences.loadFromEncryptedStorage()?.let {
|
||||
login(it)
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
fun login(key: String) {
|
||||
@@ -47,18 +52,24 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
||||
Account(Persona(Hex.decode(key)))
|
||||
}
|
||||
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
|
||||
login(account)
|
||||
}
|
||||
|
||||
fun switchUser(npub: String) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.switchToAccount(npub)
|
||||
tryLoginExistingAccount()
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
val account = Account(Persona())
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
login(account)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun login(account: Account) {
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
|
||||
if (account.loggedIn.privKey != null) {
|
||||
_accountContent.update { AccountState.LoggedIn(account) }
|
||||
} else {
|
||||
@@ -75,16 +86,16 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
localPreferences.saveToEncryptedStorage(it.account)
|
||||
LocalPreferences.saveToEncryptedStorage(it.account)
|
||||
}
|
||||
}
|
||||
|
||||
fun logOff() {
|
||||
val state = accountContent.value
|
||||
|
||||
when (state) {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun prepareLogoutOrSwitch() {
|
||||
when (val state = accountContent.value) {
|
||||
is AccountState.LoggedIn -> {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
state.account.saveable.removeObserver(saveListener)
|
||||
@@ -99,7 +110,11 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences) : Vi
|
||||
}
|
||||
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
}
|
||||
|
||||
localPreferences.clearEncryptedStorage()
|
||||
fun logOff(npub: String) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.updatePrefsForLogout(npub)
|
||||
tryLoginExistingAccount()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,26 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
||||
@@ -27,40 +31,31 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is CardFeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is CardFeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is CardFeedState.Loaded -> {
|
||||
refreshing = false
|
||||
FeedLoaded(
|
||||
state,
|
||||
accountViewModel,
|
||||
@@ -74,6 +69,8 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +90,7 @@ private fun FeedLoaded(
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
|
||||
when (item) {
|
||||
is NoteCard -> NoteCompose(
|
||||
item.note,
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -21,12 +20,12 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?, onWantsToReply: (Note) -> Unit) {
|
||||
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String, onWantsToReply: (Note) -> Unit) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val listState = rememberForeverLazyListState(routeForLastRead)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
@@ -63,8 +62,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
|
||||
reverseLayout = true,
|
||||
state = listState
|
||||
) {
|
||||
var previousDate: String = ""
|
||||
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController, onWantsToReply = onWantsToReply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -14,18 +19,19 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ChatroomListFeedView(
|
||||
viewModel: FeedViewModel,
|
||||
@@ -35,24 +41,11 @@ fun ChatroomListFeedView(
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
@@ -61,17 +54,18 @@ fun ChatroomListFeedView(
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
refreshing = false
|
||||
FeedLoaded(state, accountViewModel, navController, markAsRead)
|
||||
}
|
||||
|
||||
@@ -81,6 +75,8 @@ fun ChatroomListFeedView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,29 +93,30 @@ private fun FeedLoaded(
|
||||
val account = accountState?.account ?: return
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
LaunchedEffect(key1 = markAsRead.value) {
|
||||
if (markAsRead.value) {
|
||||
for (note in state.feed.value) {
|
||||
note.event?.let {
|
||||
var route = ""
|
||||
val channel = note.channel()
|
||||
|
||||
if (channel != null) {
|
||||
route = "Channel/${channel.idHex}"
|
||||
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()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
route = "Room/${userToComposeOn.pubkeyHex}"
|
||||
"Room/${userToComposeOn.pubkeyHex}"
|
||||
}
|
||||
|
||||
notificationCache.cache.markAsRead(route, it.createdAt(), context)
|
||||
notificationCache.cache.markAsRead(route, it.createdAt())
|
||||
}
|
||||
}
|
||||
markAsRead.value = false
|
||||
@@ -136,7 +133,7 @@ private fun FeedLoaded(
|
||||
itemsIndexed(
|
||||
state.feed.value,
|
||||
key = { index, item -> if (index == 0) index else item.idHex }
|
||||
) { index, item ->
|
||||
) { _, item ->
|
||||
ChatroomCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -11,8 +12,12 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -25,64 +30,65 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FeedView(
|
||||
viewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
routeForLastRead: String?
|
||||
routeForLastRead: String?,
|
||||
scrollStateKey: String? = null,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column {
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
refreshing = false
|
||||
FeedLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
accountViewModel,
|
||||
navController
|
||||
navController,
|
||||
scrollStateKey,
|
||||
scrollToTop
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +97,21 @@ private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
navController: NavController,
|
||||
scrollStateKey: String?,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val listState = if (scrollStateKey != null) {
|
||||
rememberForeverLazyListState(scrollStateKey)
|
||||
} else {
|
||||
rememberLazyListState()
|
||||
}
|
||||
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(Unit) {
|
||||
listState.scrollToItem(index = 0)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
@@ -102,7 +120,7 @@ private fun FeedLoaded(
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
|
||||
NoteCompose(
|
||||
item,
|
||||
isBoostedNote = false,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
|
||||
private val savedScrollStates = mutableMapOf<String, ScrollState>()
|
||||
private data class ScrollState(val index: Int, val scrollOffset: Int)
|
||||
|
||||
object ScrollStateKeys {
|
||||
const val GLOBAL_SCREEN = "Global"
|
||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberForeverLazyListState(
|
||||
key: String,
|
||||
initialFirstVisibleItemIndex: Int = 0,
|
||||
initialFirstVisibleItemScrollOffset: Int = 0
|
||||
): LazyListState {
|
||||
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
|
||||
LazyListState(
|
||||
savedIndex,
|
||||
savedOffset
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.firstVisibleItemIndex
|
||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
@@ -2,59 +2,54 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is LnZapFeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is LnZapFeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is LnZapFeedState.Loaded -> {
|
||||
refreshing = false
|
||||
LnZapFeedLoaded(state, accountViewModel, navController)
|
||||
}
|
||||
is LnZapFeedState.Loading -> {
|
||||
@@ -63,6 +58,8 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +78,7 @@ private fun LnZapFeedLoaded(
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.second.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.second.idHex }) { _, item ->
|
||||
ZapNoteCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.model.RelayInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
@@ -50,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 {
|
||||
@@ -101,16 +104,14 @@ class RelayFeedViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
var wantsToAddRelay by remember {
|
||||
mutableStateOf("")
|
||||
}
|
||||
@@ -119,19 +120,11 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
|
||||
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay)
|
||||
}
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -142,16 +135,17 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { _, item ->
|
||||
RelayCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
onAddRelay = { wantsToAddRelay = item.url },
|
||||
onRemoveRelay = { wantsToAddRelay = item.url }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -14,12 +17,16 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -42,8 +49,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
@@ -56,48 +61,40 @@ import com.vitorpamplona.amethyst.ui.note.HiddenNote
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
refreshing = false
|
||||
LaunchedEffect(noteId) {
|
||||
// waits to load the thread to scroll to item.
|
||||
delay(100)
|
||||
@@ -160,6 +157,8 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +186,7 @@ fun Modifier.drawReplyLevel(level: Int, color: Color, selected: Color): Modifier
|
||||
}
|
||||
.padding(start = (2 + (level * 3)).dp)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NoteMaster(
|
||||
baseNote: Note,
|
||||
@@ -211,6 +211,8 @@ fun NoteMaster(
|
||||
|
||||
val noteEvent = note?.event
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (noteEvent == null) {
|
||||
BlankNote()
|
||||
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
|
||||
@@ -314,7 +316,14 @@ fun NoteMaster(
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Column() {
|
||||
val eventContent = note.event?.content()
|
||||
|
||||
@@ -343,5 +352,7 @@ fun NoteMaster(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,59 +2,54 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
}
|
||||
) {
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is UserFeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is UserFeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
refreshing = true
|
||||
}
|
||||
}
|
||||
is UserFeedState.Loaded -> {
|
||||
refreshing = false
|
||||
FeedLoaded(state, accountViewModel, navController)
|
||||
}
|
||||
is UserFeedState.Loading -> {
|
||||
@@ -63,6 +58,8 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +78,7 @@ private fun FeedLoaded(
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { _, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ private fun authenticatedCopyNSec(
|
||||
return
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun keyguardPrompt() {
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
|
||||
context.getString(R.string.app_name_release),
|
||||
|
||||
@@ -97,19 +97,19 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
return account.decryptContent(note)
|
||||
}
|
||||
|
||||
fun hide(user: User, ctx: Context) {
|
||||
fun hide(user: User) {
|
||||
account.hideUser(user.pubkeyHex)
|
||||
}
|
||||
|
||||
fun show(user: User, ctx: Context) {
|
||||
fun show(user: User) {
|
||||
account.showUser(user.pubkeyHex)
|
||||
}
|
||||
|
||||
fun translateTo(lang: Locale, ctx: Context) {
|
||||
fun translateTo(lang: Locale) {
|
||||
account.updateTranslateTo(lang.language)
|
||||
}
|
||||
|
||||
fun dontTranslateFrom(lang: String, ctx: Context) {
|
||||
fun dontTranslateFrom(lang: String) {
|
||||
account.addDontTranslateFrom(lang)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -43,7 +44,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
@@ -61,29 +61,34 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
|
||||
|
||||
@Composable
|
||||
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
|
||||
fun ChannelScreen(
|
||||
channelId: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
val context = LocalContext.current
|
||||
val channelScreenModel: NewPostViewModel = viewModel()
|
||||
|
||||
if (account != null && channelId != null) {
|
||||
val newPost = remember { mutableStateOf(TextFieldValue("")) }
|
||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
ChannelFeedFilter.loadMessagesBetween(account, channelId)
|
||||
@@ -97,10 +102,13 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
channelScreenModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(channelId) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Channel Start")
|
||||
NostrChannelDataSource.start()
|
||||
@@ -177,8 +185,10 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
value = newPost.value,
|
||||
onValueChange = { newPost.value = it },
|
||||
value = channelScreenModel.message,
|
||||
onValueChange = {
|
||||
channelScreenModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
@@ -194,15 +204,24 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
|
||||
trailingIcon = {
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendChannelMessage(newPost.value.text, channel.idHex, replyTo.value, null)
|
||||
newPost.value = TextFieldValue("")
|
||||
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
|
||||
channelScreenModel.message = TextFieldValue("")
|
||||
replyTo.value = null
|
||||
feedViewModel.refresh() // Don't wait a full second before updating
|
||||
},
|
||||
newPost.value.text.isNotBlank(),
|
||||
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = channelScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
) {
|
||||
channelScreenModel.upload(it, context)
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
@@ -223,11 +242,9 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont
|
||||
Column() {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = channel.idHex,
|
||||
model = ResizeImage(channel.profilePicture(), 35.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(context, channel.idHex)),
|
||||
contentDescription = context.getString(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
|
||||
@@ -145,7 +145,7 @@ fun TabKnown(
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.resetFilters()
|
||||
feedViewModel.refresh()
|
||||
@@ -186,7 +186,7 @@ fun TabNew(
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.resetFilters()
|
||||
feedViewModel.refresh()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -36,7 +37,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -49,14 +49,15 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
|
||||
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.UsernameDisplay
|
||||
@@ -67,9 +68,10 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel
|
||||
fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
val context = LocalContext.current
|
||||
val chatRoomScreenModel: NewPostViewModel = viewModel()
|
||||
|
||||
if (account != null && userId != null) {
|
||||
val newPost = remember { mutableStateOf(TextFieldValue("")) }
|
||||
val replyTo = remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
ChatroomFeedFilter.loadMessagesBetween(account, userId)
|
||||
@@ -80,10 +82,13 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
|
||||
LaunchedEffect(userId) {
|
||||
feedViewModel.refresh()
|
||||
chatRoomScreenModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(userId) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Private Message Start")
|
||||
NostrChatroomDataSource.start()
|
||||
@@ -103,11 +108,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
NostrChatroomDataSource.withUser?.let {
|
||||
ChatroomHeader(
|
||||
it,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
ChatroomHeader(it, navController = navController)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -163,8 +164,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
value = newPost.value,
|
||||
onValueChange = { newPost.value = it },
|
||||
value = chatRoomScreenModel.message,
|
||||
onValueChange = { chatRoomScreenModel.updateMessage(it) },
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
@@ -180,15 +181,24 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
trailingIcon = {
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendPrivateMeesage(newPost.value.text, userId, replyTo.value)
|
||||
newPost.value = TextFieldValue("")
|
||||
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
|
||||
chatRoomScreenModel.message = TextFieldValue("")
|
||||
replyTo.value = null
|
||||
feedViewModel.refresh() // Don't wait a full second before updating
|
||||
},
|
||||
newPost.value.text.isNotBlank(),
|
||||
isActive = chatRoomScreenModel.message.text.isNotBlank() && !chatRoomScreenModel.isUploadingImage,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = chatRoomScreenModel.isUploadingImage,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
) {
|
||||
chatRoomScreenModel.upload(it, context)
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
@@ -200,9 +210,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
fun ChatroomHeader(baseUser: User, navController: NavController) {
|
||||
Column(
|
||||
modifier = Modifier.clickable(
|
||||
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
|
||||
@@ -213,11 +221,9 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
|
||||
val authorState by baseUser.live().metadata.observeAsState()
|
||||
val author = authorState?.user!!
|
||||
|
||||
AsyncImageProxy(
|
||||
RobohashAsyncImageProxy(
|
||||
robot = author.pubkeyHex,
|
||||
model = ResizeImage(author.profilePicture(), 35.dp),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, author.pubkeyHex)),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
|
||||
@@ -11,8 +11,6 @@ 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.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
@@ -20,12 +18,11 @@ import androidx.compose.ui.res.stringResource
|
||||
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.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
@@ -34,37 +31,37 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
|
||||
val feedViewModel: NostrHomeFeedViewModel = viewModel()
|
||||
val feedViewModelReplies: NostrHomeRepliesFeedViewModel = viewModel()
|
||||
|
||||
val pagerState = rememberPagerState()
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
pagerState: PagerState,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
NostrHomeDataSource.resetFilters()
|
||||
|
||||
feedViewModel.refresh()
|
||||
feedViewModelReplies.refresh()
|
||||
homeFeedViewModel.refresh()
|
||||
repliesFeedViewModel.refresh()
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrHomeDataSource.resetFilters()
|
||||
feedViewModel.refresh()
|
||||
feedViewModelReplies.refresh()
|
||||
homeFeedViewModel.refresh()
|
||||
repliesFeedViewModel.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +103,8 @@ fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController)
|
||||
}
|
||||
HorizontalPager(count = 2, state = pagerState) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> FeedView(feedViewModel, accountViewModel, navController, Route.Home.route + "Follows")
|
||||
1 -> FeedView(feedViewModelReplies, accountViewModel, navController, Route.Home.route + "FollowsReplies")
|
||||
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
|
||||
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.DrawerValue
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ModalBottomSheetLayout
|
||||
import androidx.compose.material.ModalBottomSheetValue
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.rememberDrawerState
|
||||
import androidx.compose.material.rememberModalBottomSheetState
|
||||
import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -19,6 +23,7 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.buttons.NewChannelButton
|
||||
import com.vitorpamplona.amethyst.buttons.NewNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
|
||||
@@ -28,31 +33,44 @@ import com.vitorpamplona.amethyst.ui.navigation.currentRoute
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, startingPage: String? = null) {
|
||||
val navController = rememberNavController()
|
||||
val scaffoldState = rememberScaffoldState(rememberDrawerState(DrawerValue.Closed))
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
initialValue = ModalBottomSheetValue.Hidden,
|
||||
confirmValueChange = { it != ModalBottomSheetValue.HalfExpanded },
|
||||
skipHalfExpanded = true
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primaryVariant)
|
||||
.statusBarsPadding(),
|
||||
bottomBar = {
|
||||
AppBottomBar(navController, accountViewModel)
|
||||
},
|
||||
topBar = {
|
||||
AppTopBar(navController, scaffoldState, accountViewModel)
|
||||
},
|
||||
drawerContent = {
|
||||
DrawerContent(navController, scaffoldState, accountViewModel, accountStateViewModel)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingButton(navController, accountStateViewModel)
|
||||
},
|
||||
scaffoldState = scaffoldState
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetContent = {
|
||||
AccountSwitchBottomSheet(accountViewModel = accountViewModel, accountStateViewModel = accountStateViewModel)
|
||||
}
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
|
||||
AppNavigation(navController, accountViewModel, accountStateViewModel, startingPage)
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.primaryVariant)
|
||||
.statusBarsPadding(),
|
||||
bottomBar = {
|
||||
AppBottomBar(navController, accountViewModel)
|
||||
},
|
||||
topBar = {
|
||||
AppTopBar(navController, scaffoldState, accountViewModel)
|
||||
},
|
||||
drawerContent = {
|
||||
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingButton(navController, accountStateViewModel)
|
||||
},
|
||||
scaffoldState = scaffoldState
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
|
||||
AppNavigation(navController, accountViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +79,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) {
|
||||
val accountState by accountViewModel.accountContent.collectAsState()
|
||||
|
||||
if (currentRoute(navController) == Route.Home.route) {
|
||||
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
|
||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
@@ -77,7 +95,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRoute(navController) == Route.Message.route) {
|
||||
if (currentRoute(navController) == Route.Message.base) {
|
||||
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
|
||||
@@ -34,7 +34,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
@@ -21,14 +21,12 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -51,7 +49,6 @@ import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -65,6 +62,8 @@ import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
||||
@@ -111,7 +110,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Profile Start")
|
||||
NostrUserProfileDataSource.loadUserProfile(userId)
|
||||
@@ -199,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)}")
|
||||
},
|
||||
@@ -237,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)}")
|
||||
}
|
||||
@@ -259,13 +258,13 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
}
|
||||
) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> TabNotesNewThreads(baseUser, accountViewModel, navController)
|
||||
1 -> TabNotesConversations(baseUser, accountViewModel, navController)
|
||||
0 -> TabNotesNewThreads(accountViewModel, navController)
|
||||
1 -> TabNotesConversations(accountViewModel, navController)
|
||||
2 -> TabFollows(baseUser, accountViewModel, navController)
|
||||
3 -> TabFollowers(baseUser, accountViewModel, navController)
|
||||
4 -> TabReceivedZaps(baseUser, accountViewModel, navController)
|
||||
5 -> TabReports(baseUser, accountViewModel, navController)
|
||||
6 -> TabRelays(baseUser, accountViewModel, navController)
|
||||
6 -> TabRelays(baseUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +334,7 @@ private fun ProfileHeader(
|
||||
baseUser = baseUser,
|
||||
baseUserAccount = account.userProfile(),
|
||||
size = 100.dp,
|
||||
pictureModifier = Modifier.border(
|
||||
modifier = Modifier.border(
|
||||
3.dp,
|
||||
MaterialTheme.colors.background,
|
||||
CircleShape
|
||||
@@ -432,13 +431,17 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, navController:
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(30.dp).padding(start = 5.dp),
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.padding(start = 5.dp),
|
||||
onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
null,
|
||||
modifier = Modifier.padding(end = 5.dp).size(15.dp),
|
||||
modifier = Modifier
|
||||
.padding(end = 5.dp)
|
||||
.size(15.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
@@ -572,28 +575,24 @@ fun BadgeThumb(
|
||||
val event = (note.event as? BadgeDefinitionEvent)
|
||||
val image = event?.thumb() ?: event?.image()
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)
|
||||
) {
|
||||
if (image == null) {
|
||||
Image(
|
||||
painter = BitmapPainter(RoboHashCache.get(ctx, "ohnothisauthorisnotfound")),
|
||||
RobohashAsyncImage(
|
||||
robot = "authornotfound",
|
||||
contentDescription = stringResource(R.string.unknown_author),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = note.idHex,
|
||||
model = image,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
placeholder = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
fallback = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
error = BitmapPainter(RoboHashCache.get(ctx, note.idHex)),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
@@ -654,7 +653,7 @@ private fun DrawBanner(baseUser: User) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesNewThreads(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
@@ -674,7 +673,7 @@ fun TabNotesNewThreads(user: User, accountViewModel: AccountViewModel, navContro
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesConversations(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun TabNotesConversations(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
@@ -770,13 +769,13 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun TabRelays(user: User, accountViewModel: AccountViewModel) {
|
||||
val feedViewModel: RelayFeedViewModel = viewModel()
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(user) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Profile Relay Start")
|
||||
feedViewModel.subscribeTo(user)
|
||||
@@ -799,7 +798,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: Nav
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
RelayFeedView(feedViewModel, accountViewModel, navController)
|
||||
RelayFeedView(feedViewModel, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -941,8 +940,6 @@ fun ShowUserButton(onClick: () -> Unit) {
|
||||
@Composable
|
||||
fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -958,52 +955,51 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
|
||||
Divider()
|
||||
if (account.isHidden(user)) {
|
||||
DropdownMenuItem(onClick = {
|
||||
user.let {
|
||||
accountViewModel.show(
|
||||
it,
|
||||
context
|
||||
)
|
||||
}; onDismiss()
|
||||
accountViewModel.show(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.unblock_user))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { user.let { accountViewModel.hide(it, context) }; onDismiss() }) {
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.block_hide_user))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.SPAM)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.report_spam_scam))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.PROFANITY)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_hateful_speech))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.report_impersonation))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.NUDITY)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(R.string.report_nudity_porn))
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL)
|
||||
user.let { accountViewModel.hide(it, context) }
|
||||
accountViewModel.hide(user)
|
||||
onDismiss()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.report_illegal_behaviour))
|
||||
|
||||
@@ -36,8 +36,6 @@ 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.painter.BitmapPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -47,10 +45,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
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.R
|
||||
import com.vitorpamplona.amethyst.RoboHashCache
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -65,7 +61,8 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -78,20 +75,23 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
fun SearchScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
feedViewModel: FeedViewModel,
|
||||
navController: NavController,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
GlobalFeedFilter.account = account
|
||||
NostrGlobalDataSource.resetFilters()
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Global Start")
|
||||
NostrGlobalDataSource.start()
|
||||
@@ -114,7 +114,7 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
SearchBar(accountViewModel, navController)
|
||||
FeedView(feedViewModel, accountViewModel, navController, null)
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,8 +130,6 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
val isTrailingIconVisible by remember {
|
||||
derivedStateOf {
|
||||
searchValue.isNotBlank()
|
||||
@@ -240,14 +238,14 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
bottom = 10.dp
|
||||
)
|
||||
) {
|
||||
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { index, item ->
|
||||
itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { index, item ->
|
||||
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelPicturePlaceholder = BitmapPainter(RoboHashCache.get(ctx, item.idHex)),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
@@ -261,7 +259,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { index, item ->
|
||||
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
|
||||
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { source, event ->
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Thread Start")
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedOff
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -36,14 +36,18 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||
fun LoginPage(
|
||||
accountViewModel: AccountStateViewModel,
|
||||
isFirstLogin: Boolean
|
||||
) {
|
||||
val key = remember { mutableStateOf(TextFieldValue("")) }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
val acceptedTerms = remember { mutableStateOf(false) }
|
||||
val acceptedTerms = remember { mutableStateOf(!isFirstLogin) }
|
||||
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
|
||||
val uri = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
@@ -147,48 +151,50 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = acceptedTerms.value,
|
||||
onCheckedChange = { acceptedTerms.value = it }
|
||||
)
|
||||
if (isFirstLogin) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = acceptedTerms.value,
|
||||
onCheckedChange = { acceptedTerms.value = it }
|
||||
)
|
||||
|
||||
val regularText =
|
||||
SpanStyle(color = MaterialTheme.colors.onBackground)
|
||||
val regularText =
|
||||
SpanStyle(color = MaterialTheme.colors.onBackground)
|
||||
|
||||
val clickableTextStyle =
|
||||
SpanStyle(color = MaterialTheme.colors.primary)
|
||||
val clickableTextStyle =
|
||||
SpanStyle(color = MaterialTheme.colors.primary)
|
||||
|
||||
val annotatedTermsString = buildAnnotatedString {
|
||||
withStyle(regularText) {
|
||||
append(stringResource(R.string.i_accept_the))
|
||||
}
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("openTerms", "")
|
||||
append(stringResource(R.string.terms_of_use))
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = annotatedTermsString
|
||||
) { spanOffset ->
|
||||
annotatedTermsString.getStringAnnotations(spanOffset, spanOffset)
|
||||
.firstOrNull()
|
||||
?.also { span ->
|
||||
if (span.tag == "openTerms") {
|
||||
runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") }
|
||||
}
|
||||
val annotatedTermsString = buildAnnotatedString {
|
||||
withStyle(regularText) {
|
||||
append(stringResource(R.string.i_accept_the))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (termsAcceptanceIsRequired.isNotBlank()) {
|
||||
Text(
|
||||
text = termsAcceptanceIsRequired,
|
||||
color = MaterialTheme.colors.error,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("openTerms", "")
|
||||
append(stringResource(R.string.terms_of_use))
|
||||
}
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = annotatedTermsString
|
||||
) { spanOffset ->
|
||||
annotatedTermsString.getStringAnnotations(spanOffset, spanOffset)
|
||||
.firstOrNull()
|
||||
?.also { span ->
|
||||
if (span.tag == "openTerms") {
|
||||
runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (termsAcceptanceIsRequired.isNotBlank()) {
|
||||
Text(
|
||||
text = termsAcceptanceIsRequired,
|
||||
color = MaterialTheme.colors.error,
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user