Compare commits

...

118 Commits

Author SHA1 Message Date
Vitor Pamplona
0272073636 Merge remote-tracking branch 'origin/HEAD' 2023-03-14 22:01:54 -04:00
Vitor Pamplona
063ef1e3a9 v0.26.2 Fdroid build 2023-03-14 21:59:59 -04:00
Vitor Pamplona
4ec7af3265 Merge pull request #279 from maxmoney21m/feature/54-reproducible-build
Add .profm sorting to build.gradle
2023-03-14 21:58:51 -04:00
maxmoney21m
f09f472f26 Add .profm sorting to build.gradle 2023-03-15 09:37:57 +08:00
Vitor Pamplona
2e365b67ea Filtering duplicates from Follows list 2023-03-14 19:15:40 -04:00
Vitor Pamplona
ee753e5c95 v0.26.1 2023-03-14 19:09:30 -04:00
Vitor Pamplona
8b53d28721 Doesn't crash when a non-hex key is inserted in the contact list 2023-03-14 19:08:06 -04:00
Vitor Pamplona
f8bb4a3168 v0.26.0 2023-03-14 18:30:48 -04:00
Vitor Pamplona
1b8923b6d7 Speeding up Notification and Global feed filters 2023-03-14 18:30:26 -04:00
Vitor Pamplona
675b59c0d2 Merge pull request #233 from vitorpamplona/less_memory_test_branch
Restructuring User/Note
2023-03-14 17:56:38 -04:00
Vitor Pamplona
a71078f90f Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-14 17:46:29 -04:00
Vitor Pamplona
d4be271060 Fixing typos in the path 2023-03-14 16:27:28 -04:00
Vitor Pamplona
2d59baf898 Uses unique IDs for each job in Actions 2023-03-14 16:14:17 -04:00
Vitor Pamplona
18f651b67f v0.25.3 Testing Build Scripts 2023-03-14 16:03:44 -04:00
Vitor Pamplona
f65e09f388 Adding scripts to release all variants of the app 2023-03-14 15:55:08 -04:00
Vitor Pamplona
a4bb01ef7d Merge pull request #276 from maxmoney21m/feature/54-fdroid-flavor
Make release channel flavors, remove mlkit from fdroid channel
2023-03-14 15:26:11 -04:00
maxmoney21m
e9c0d350e1 Make release channel flavors, remove mlkit from fdroid channel 2023-03-15 02:34:50 +08:00
Vitor Pamplona
faa29675db 0.25.2 2023-03-14 13:01:13 -04:00
Vitor Pamplona
96990c44a6 Merge pull request #274 from vitorpamplona/pr/273
Pr/273
2023-03-14 12:19:46 -04:00
Vitor Pamplona
424fe5fd3c Merge pull request #273 from believethehype/main
Image upload button in Chats
2023-03-14 12:19:17 -04:00
Vitor Pamplona
06d3784261 Moving Upload Image buttons to be inside the TextEdit 2023-03-14 12:02:25 -04:00
Believethehype
9e3c08ade8 Image upload button in Chats 2023-03-14 15:27:58 +01:00
Vitor Pamplona
3b7fc7173d This call already happens inside Login 2023-03-14 09:46:39 -04:00
Vitor Pamplona
105c7b9fe8 Avoids adding an empty string when the list is empty because it will parse it back as a list with 1 element with an empty npub. 2023-03-14 09:46:09 -04:00
Vitor Pamplona
c0c45d1385 Merge pull request #270 from Bardesss/patch-2
Update NL strings.xml
2023-03-14 08:48:17 -04:00
Vitor Pamplona
9b2ce25668 Merge pull request #271 from maxmoney21m/bugfix/account-switch-tabs
Bugfix: Reset feedfilter accounts when switching accounts
2023-03-14 08:48:04 -04:00
Vitor Pamplona
7e2b28503e Merge pull request #269 from maxmoney21m/bugfix/apptopbar-bgcolor
Fix AppTopBar background color
2023-03-14 08:47:37 -04:00
maxmoney21m
d798db7cf1 Reset FeedFilter account when account changes 2023-03-14 18:23:04 +08:00
Bardesss
aa2f718589 Update NL strings.xml
Added missing strings an fixed some mistakes.
2023-03-14 09:26:35 +00:00
maxmoney21m
f3a4a7e143 Fix AppTopBar background color 2023-03-14 13:53:38 +08:00
Vitor Pamplona
732abb70f3 v0.25.1 2023-03-13 22:01:35 -04:00
Vitor Pamplona
8c98a8849c Adding the same button style for before and after granted permissions 2023-03-13 17:57:51 -04:00
Vitor Pamplona
5ce6d06920 Reverting QuickAction to the DropDown Menu until we get reporting functions implemented. 2023-03-13 17:46:09 -04:00
Vitor Pamplona
bb6b4c208d Moves SVG generation to fetcher (IO) and avoids the use of TrimIndent, which is too slow (2-10ms each) for this context. 2023-03-13 17:38:30 -04:00
Vitor Pamplona
ea63522745 Updating dependencies 2023-03-13 17:36:47 -04:00
Vitor Pamplona
698c9b00cb v0.25.0 2023-03-13 15:37:55 -04:00
Vitor Pamplona
1c740d4d7a Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
2023-03-13 14:51:44 -04:00
Vitor Pamplona
0491c25e9f Merge pull request #268 from maxmoney21m/fix-all-warnings
Fixed (almost) all test warnings
2023-03-13 14:47:17 -04:00
Vitor Pamplona
75ed380449 Fixing merge issue 2023-03-13 14:32:21 -04:00
maxmoney21m
2820197905 Fixed (almost) all test warnings 2023-03-14 02:32:00 +08:00
Vitor Pamplona
01818ff458 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch
# Conflicts:
#	.idea/misc.xml
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt
2023-03-13 14:09:17 -04:00
Vitor Pamplona
d19592aef3 Activates .mov video previews 2023-03-13 13:49:06 -04:00
Vitor Pamplona
9c4f9fce37 Merge pull request #267 from matata2140/patch-1
Update strings.xml
2023-03-13 13:37:27 -04:00
Vitor Pamplona
c06ed8d338 Merge pull request #258 from maxmoney21m/215-robohash-identicons
Generate robohash using SVGs
2023-03-13 13:36:05 -04:00
maxmoney21m
0f42c2707e Rebase on main / fix merge conflicts 2023-03-14 01:08:15 +08:00
maxmoney21m
5c518501ab Edit background shape, use painter placeholder/fallback
Use logo-shaped background

Use painter as placeholder and fallback
2023-03-14 01:07:48 +08:00
maxmoney21m
887d963c5b Remove original robohash dependency 2023-03-14 01:07:43 +08:00
maxmoney21m
0eb21a6650 Replace remaining avatars with robot fallback images 2023-03-14 01:07:42 +08:00
maxmoney21m
9f6867b6ab Switch PFPs to use the robohash proxy 2023-03-14 01:07:31 +08:00
maxmoney21m
6403bd21f8 Add async image proxy for robohash images 2023-03-14 00:53:22 +08:00
maxmoney21m
b3f2a03295 Add RoboHash SVG generator 2023-03-14 00:53:21 +08:00
Vitor Pamplona
27b2cda14f Merge pull request #249 from maxmoney21m/feature/zxing-qr-scanner
Replace mlkit with zxing QR scanner
2023-03-13 11:27:45 -04:00
Vitor Pamplona
f61ec0b5ab Merge pull request #266 from maxmoney21m/bugfix/okhttp-ua-header
Add user-agent header to OkHttpClient requests
2023-03-13 11:09:51 -04:00
maxmoney21m
53d692ec7f Add user-agent header, fixes #259 2023-03-13 23:02:53 +08:00
Vitor Pamplona
12876cdacd Merge pull request #245 from maxmoney21m/feature/keep-feed-position
Keep feed position while navigating different screens
2023-03-13 10:55:54 -04:00
Vitor Pamplona
63a14b7409 Merge pull request #265 from maxmoney21m/bugfix/gitignore
Update gitignore
2023-03-13 10:53:33 -04:00
Vitor Pamplona
cd5e0187e8 Refining the selection size when clicking in an account. 2023-03-13 10:20:48 -04:00
maxmoney21m
02d74c611a Remove .idea files 2023-03-13 21:42:09 +08:00
maxmoney21m
a367bf5982 Update gitignore 2023-03-13 21:41:02 +08:00
maxmoney21m
d8c2e623d1 Hoist HomeScreen state 2023-03-13 20:28:30 +08:00
maxmoney21m
70434eb244 Remove Route buildScreen member 2023-03-13 20:17:35 +08:00
maxmoney21m
85d722f96d Hoist globalfeed state out of Screen component 2023-03-13 19:03:15 +08:00
maxmoney21m
e4cec20d1a Make forceRefresh non-null 2023-03-13 13:07:01 +08:00
maxmoney21m
bb7dcbdb78 Resolve merge conflicts on main 2023-03-13 11:35:08 +08:00
matata
260ed29309 Update strings.xml
I wasn't sure about translating 'channel' so I left them as is, but:
If 'channel' refers to 'dm group-chat' then go on and replace all 'kanaal' with 'groep'
2023-03-13 00:12:00 +01:00
Vitor Pamplona
9fc1fa9f6c Merge pull request #263 from maxmoney21m/feature/multiple-accounts
Multiple account management
2023-03-12 18:09:50 -04:00
Vitor Pamplona
199f9fbe69 Merge pull request #264 from Chemaclass/refactor-LocalCache-isValidHexNpub
Refactor LocalCache.isValidHexNpub()
2023-03-12 17:57:16 -04:00
Chemaclass
2fea7dac62 Refactor LocalCache.isValidHexNpub() 2023-03-12 18:42:17 +01:00
maxmoney21m
90147ce557 Allow tapping nav icon to refresh & scroll to top 2023-03-12 22:40:26 +08:00
maxmoney21m
4fa584ca1a Remove swiperefresh dependency 2023-03-12 22:40:26 +08:00
maxmoney21m
7630e07dc9 Replace deprecated SwipeRefresh with PullRefresh 2023-03-12 22:40:26 +08:00
maxmoney21m
5da70a0f95 Persist lazy list state on Home, Global and Chatrooms 2023-03-12 22:39:02 +08:00
maxmoney21m
be19d0a9cf Add persistent lazy list state composable 2023-03-12 22:39:02 +08:00
maxmoney21m
20431a87ea Use List instead of Set to keep account order 2023-03-12 22:37:23 +08:00
maxmoney21m
cc36dcffe0 Extract strings 2023-03-12 22:37:22 +08:00
maxmoney21m
32b50418de Indicate whether account has pubkey or privkey 2023-03-12 22:37:21 +08:00
maxmoney21m
b1cb7d599f Make account switcher scrollable 2023-03-12 22:37:21 +08:00
maxmoney21m
f34d79106d Fix account switching 2023-03-12 22:37:20 +08:00
maxmoney21m
269197a59d Migration from old to new preferences 2023-03-12 22:37:20 +08:00
maxmoney21m
992796a7bf Delete user-specific preference xml file on logout 2023-03-12 22:37:19 +08:00
maxmoney21m
e472a494a1 Re-use existing LoginPage component in account switcher 2023-03-12 22:37:18 +08:00
maxmoney21m
b40bde10a0 Add account, switch account, and logout flows 2023-03-12 22:37:17 +08:00
maxmoney21m
9561261bf4 Add note 2023-03-12 22:37:17 +08:00
maxmoney21m
3a2403b344 Use multiple preference files for different accounts 2023-03-12 22:37:16 +08:00
maxmoney21m
b7f8241a08 Add account switch bottom sheet and update LocalPrefs 2023-03-12 22:37:15 +08:00
Vitor Pamplona
907aa7b734 Merge pull request #256 from maxmoney21m/bugfix/note-quick-actions
Add quick action menu in chats and threads
2023-03-12 10:26:31 -04:00
Vitor Pamplona
74336d72c0 Merge pull request #251 from h3y6e/patch-1
Correct identity claim for mastodon
2023-03-12 10:25:38 -04:00
Vitor Pamplona
0b9b288146 Merge pull request #255 from maxmoney21m/bugfix/254
Fix string
2023-03-12 10:24:10 -04:00
Vitor Pamplona
b7deaaa1a6 Merge pull request #257 from akiomik/add-string-resources
Add string resources to strings.xml
2023-03-12 10:23:48 -04:00
Vitor Pamplona
9dc9d7d947 Merge pull request #260 from Bardesss/patch-1
Fixed NL translation
2023-03-12 10:22:57 -04:00
Vitor Pamplona
a69a5b1a1e Merge pull request #261 from Chemaclass/refactor-LocalCache
Refactor LocalCache
2023-03-12 10:22:41 -04:00
Vitor Pamplona
5842a84309 Merge pull request #262 from kappaseijin/fixed_var_name_for_NIP-05
Fixed a variable name for NIP-05
2023-03-12 10:19:23 -04:00
kappaseijin
332ddc15f7 Fixed a variable name for NIP-05 2023-03-12 14:43:33 +09:00
Chemaclass
30f4ff9d6f Remove indentation level LocalCache.consume(ChannelCreateEvent) 2023-03-11 20:43:46 +00:00
Chemaclass
d166f22c58 Extract checkIfValidHex() 2023-03-11 20:34:44 +00:00
Bardesss
bee361cea7 Update strings.xml
Fixed threads
2023-03-11 13:26:59 +00:00
Akiomi Kamakura
31e724662d Add string resources to strings.xml 2023-03-11 13:02:35 +09:00
maxmoney21m
6109131ef5 Add quick action menu in chats and threads 2023-03-11 11:21:16 +08:00
maxmoney21m
fe9798fdfc Fix string 2023-03-11 10:50:37 +08:00
heyhoe
a8dc03c477 Correct identity claim for mastodon 2023-03-11 03:46:07 +09:00
maxmoney21m
d4cee941ef Fix inverted QR scan 2023-03-11 01:22:23 +08:00
Vitor Pamplona
9adb376198 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-10 11:28:13 -05:00
maxmoney21m
ae6cf15768 Replace mlkit with zxing QR scanner 2023-03-10 22:14:38 +08:00
Vitor Pamplona
ccf2b06b10 Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 15:42:22 -05:00
Vitor Pamplona
f2f53c66bd Merge remote-tracking branch 'origin/HEAD' into less_memory_test_branch 2023-03-09 09:01:27 -05:00
Vitor Pamplona
b93f28f09d Merge branch 'main' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt
2023-03-08 17:47:34 -05:00
Vitor Pamplona
38df1ba6f7 Merge commit '40a6feab8a4a36813644c19ca034333b73e98132' into less_memory_test_branch 2023-03-08 17:35:30 -05:00
Vitor Pamplona
6c9aa9b6ff Merge commit '9c7a5b73b3a75a465f78c1d97726e66f0e3032a3' into less_memory_test_branch 2023-03-08 17:35:21 -05:00
Vitor Pamplona
26b581b13c Merge commit 'a41f2c38ec9683f0e9aaca3e4cffb2cd45f21145' into less_memory_test_branch 2023-03-08 17:34:40 -05:00
Vitor Pamplona
6a3cae1c8d Merge commit 'c7ba0bce5bbc3edd8ece9669751b110d59c40052' into less_memory_test_branch 2023-03-08 17:34:29 -05:00
Vitor Pamplona
40ac31983c Merge commit 'c9b859610e660f7e2d906594410bc7fbf2b1914a' into less_memory_test_branch
# Conflicts:
#	app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt
2023-03-08 17:34:22 -05:00
Vitor Pamplona
8c25667d0f Organize imports to simplify merge 2023-03-08 17:29:36 -05:00
Vitor Pamplona
6acc842d4f Readding LocalCacheImport 2023-03-08 17:18:25 -05:00
Vitor Pamplona
fc4b68b264 Merge commit 'f1b6927bb37d1c9887fb48aa5655a6d2df558e81' into less_memory_test_branch
# Conflicts:
#	app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	app/src/main/java/com/vitorpamplona/amethyst/model/User.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelCreateEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/ContactListEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/LongTextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowersFeedFilter.kt
#	app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileFollowsFeedFilter.kt
2023-03-08 17:17:03 -05:00
Vitor Pamplona
9470560002 Testing Lint Commit 2023-03-08 17:07:56 -05:00
Vitor Pamplona
cd950ee946 Merge commit '2d92e1d44491d62005e45adcf3dc1c5a403c386f' into less_memory_test_branch 2023-03-08 17:04:24 -05:00
Vitor Pamplona
f9f68e2a4f Merge commit '691b8225035d8fe59539a971a85b1df2f2f84ebe' into less_memory_test_branch 2023-03-08 17:03:42 -05:00
Vitor Pamplona
3fd656da9f Removes several Indexes from User and Notes to see if we can reduce App's memory usage. 2023-03-08 08:47:33 -05:00
106 changed files with 2716 additions and 1888 deletions

View File

@@ -28,10 +28,19 @@ jobs:
- name: Build AAB
run: ./gradlew clean bundleRelease --stacktrace
- name: Sign AAB
- name: Sign AAB (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/release
releaseDirectory: app/build/outputs/bundle/playRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign AAB (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/bundle/fdroidRelease
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -40,10 +49,19 @@ jobs:
- name: Build APK
run: ./gradlew assembleRelease --stacktrace --no-daemon
- name: Sign APK
- name: Sign APK (Google Play)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/release
releaseDirectory: app/build/outputs/apk/play/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Sign APK (F-Droid)
uses: r0adkll/sign-android-release@v1
with:
releaseDirectory: app/build/outputs/apk/fdroid/release
signingKeyBase64: ${{ secrets.SIGNING_KEY }}
alias: ${{ secrets.KEY_ALIAS }}
keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
@@ -60,24 +78,96 @@ jobs:
draft: false
prerelease: false
- name: Upload APK Asset
id: upload-release-asset-apk
# Google Play APK
- name: Upload Play APK Universal Asset
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/release/app-release-unsigned-signed.apk
asset_name: app-release-${{ github.ref_name }}.apk
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
asset_name: amethyst-googleplay-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload AAB Asset
id: upload-release-asset-aab
- name: Upload Play APK x86 Asset
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/release/app-release.aab
asset_name: app-release-${{ github.ref_name }}.aab
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload Play APK x86_64 Asset
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
asset_name: amethyst-googleplay-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# F-Droid APK
- name: Upload F-Droid APK Universal Asset
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
asset_name: amethyst-fdroid-universal-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86 Asset
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86-${{ github.ref_name }}.apk
asset_content_type: application/zip
- name: Upload F-Droid APK x86_64 Asset
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
asset_name: amethyst-fdroid-x86_64-${{ github.ref_name }}.apk
asset_content_type: application/zip
# Google Play AAB
- name: Upload Google Play AAB Asset
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
asset_name: amethyst-googleplay-${{ github.ref_name }}.aab
asset_content_type: application/zip
# FDroid AAB
- name: Upload F-Droid AAB Asset
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab
asset_name: amethyst-fdroid-${{ github.ref_name }}.aab
asset_content_type: application/zip

60
.gitignore vendored
View File

@@ -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
View File

@@ -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
View File

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

View File

@@ -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, "")
}
}
}
}
}
}
}

View File

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

View File

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

View 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
}
}

View File

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

View File

@@ -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
}
}

View File

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

View File

@@ -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"
)
}
}

View File

@@ -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
}
}

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 }
}
}
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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.
*/

View File

@@ -22,4 +22,6 @@ interface EventInterface {
fun checkSignature()
fun hasValidSignature(): Boolean
fun isTaggedUser(loggedInUser: String): Boolean
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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 {

View File

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

View File

@@ -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.

View File

@@ -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/*")
}
}
}

View File

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

View File

@@ -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"/>"""
)
)

View File

@@ -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
)
}
}

View File

@@ -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) }

View File

@@ -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 })
}
}
) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
)
})
}
}

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -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") })
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?: "",

View File

@@ -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 ?: "",

View File

@@ -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)
}
}

View File

@@ -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 { }
}
}

View File

@@ -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)
}
}
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -167,6 +167,7 @@ private fun authenticatedCopyNSec(
return
}
@Suppress("DEPRECATION")
fun keyguardPrompt() {
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
context.getString(R.string.app_name_release),

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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 -> {

View File

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

View File

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

View File

@@ -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)
}
}

View File

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

View File

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